Compare commits
6 Commits
fix/ci-wor
...
whip/492-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837c62945e | ||
| e3a40be627 | |||
| efb2df8940 | |||
| cf687a5bfa | |||
|
|
c09e54de72 | ||
|
|
b71e365ed6 |
97
bin/tmux-resume.sh
Executable file
97
bin/tmux-resume.sh
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── tmux-resume.sh — Cold-start Session Resume ───────────────────────────
|
||||
# Reads ~/.timmy/tmux-state.json and resumes hermes sessions.
|
||||
# Run at startup to restore pane state after supervisor restart.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="${HOME}/.timmy/tmux-state.json"
|
||||
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "[tmux-resume] No manifest found at $MANIFEST — starting fresh."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 << 'PYEOF'
|
||||
import json, subprocess, os, sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
MANIFEST = os.path.expanduser("~/.timmy/tmux-state.json")
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
||||
return r.stdout.strip(), r.returncode
|
||||
except Exception as e:
|
||||
return str(e), 1
|
||||
|
||||
def session_exists(name):
|
||||
out, _ = run(f"tmux has-session -t '{name}' 2>&1")
|
||||
return "can't find" not in out.lower()
|
||||
|
||||
with open(MANIFEST) as f:
|
||||
state = json.load(f)
|
||||
|
||||
ts = state.get("timestamp", "unknown")
|
||||
age = "unknown"
|
||||
try:
|
||||
t = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
delta = datetime.now(timezone.utc) - t
|
||||
mins = int(delta.total_seconds() / 60)
|
||||
if mins < 60:
|
||||
age = f"{mins}m ago"
|
||||
else:
|
||||
age = f"{mins//60}h {mins%60}m ago"
|
||||
except:
|
||||
pass
|
||||
|
||||
print(f"[tmux-resume] Manifest from {age}: {state['summary']['total_sessions']} sessions, "
|
||||
f"{state['summary']['hermes_panes']} hermes panes")
|
||||
|
||||
restored = 0
|
||||
skipped = 0
|
||||
|
||||
for pane in state.get("panes", []):
|
||||
if not pane.get("is_hermes"):
|
||||
continue
|
||||
|
||||
addr = pane["address"] # e.g. "BURN:2.3"
|
||||
session = addr.split(":")[0]
|
||||
session_id = pane.get("session_id")
|
||||
profile = pane.get("profile", "default")
|
||||
model = pane.get("model", "")
|
||||
task = pane.get("task", "")
|
||||
|
||||
# Skip if session already exists (already running)
|
||||
if session_exists(session):
|
||||
print(f" [skip] {addr} — session '{session}' already exists")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Respawn hermes with session resume if we have a session ID
|
||||
if session_id:
|
||||
print(f" [resume] {addr} — profile={profile} model={model} session={session_id}")
|
||||
cmd = f"hermes chat --resume {session_id}"
|
||||
else:
|
||||
print(f" [start] {addr} — profile={profile} model={model} (no session ID)")
|
||||
cmd = f"hermes chat --profile {profile}"
|
||||
|
||||
# Create tmux session and run hermes
|
||||
run(f"tmux new-session -d -s '{session}' -n '{session}:0'")
|
||||
run(f"tmux send-keys -t '{session}' '{cmd}' Enter")
|
||||
restored += 1
|
||||
|
||||
# Write resume log
|
||||
log = {
|
||||
"resumed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"manifest_age": age,
|
||||
"restored": restored,
|
||||
"skipped": skipped,
|
||||
}
|
||||
log_path = os.path.expanduser("~/.timmy/tmux-resume.log")
|
||||
with open(log_path, "w") as f:
|
||||
json.dump(log, f, indent=2)
|
||||
|
||||
print(f"[tmux-resume] Done: {restored} restored, {skipped} skipped")
|
||||
PYEOF
|
||||
237
bin/tmux-state.sh
Executable file
237
bin/tmux-state.sh
Executable file
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── tmux-state.sh — Session State Persistence Manifest ───────────────────
|
||||
# Snapshots all tmux pane state to ~/.timmy/tmux-state.json
|
||||
# Run every supervisor cycle. Cold-start reads this manifest to resume.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="${HOME}/.timmy/tmux-state.json"
|
||||
mkdir -p "$(dirname "$MANIFEST")"
|
||||
|
||||
python3 << 'PYEOF'
|
||||
import json, subprocess, os, time, re, sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
MANIFEST = os.path.expanduser("~/.timmy/tmux-state.json")
|
||||
|
||||
def run(cmd):
|
||||
"""Run command, return stdout or empty string."""
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
|
||||
return r.stdout.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def get_sessions():
|
||||
"""Get all tmux sessions with metadata."""
|
||||
raw = run("tmux list-sessions -F '#{session_name}|#{session_windows}|#{session_created}|#{session_attached}|#{session_group}|#{session_id}'")
|
||||
sessions = []
|
||||
for line in raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|")
|
||||
if len(parts) < 6:
|
||||
continue
|
||||
sessions.append({
|
||||
"name": parts[0],
|
||||
"windows": int(parts[1]),
|
||||
"created_epoch": int(parts[2]),
|
||||
"created": datetime.fromtimestamp(int(parts[2]), tz=timezone.utc).isoformat(),
|
||||
"attached": parts[3] == "1",
|
||||
"group": parts[4],
|
||||
"id": parts[5],
|
||||
})
|
||||
return sessions
|
||||
|
||||
def get_panes():
|
||||
"""Get all tmux panes with full metadata."""
|
||||
fmt = '#{session_name}|#{window_index}|#{pane_index}|#{pane_pid}|#{pane_title}|#{pane_width}x#{pane_height}|#{pane_active}|#{pane_current_command}|#{pane_start_command}|#{pane_tty}|#{pane_id}|#{window_name}|#{session_id}'
|
||||
raw = run(f"tmux list-panes -a -F '{fmt}'")
|
||||
panes = []
|
||||
for line in raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|")
|
||||
if len(parts) < 13:
|
||||
continue
|
||||
session, win, pane, pid, title, size, active, cmd, start_cmd, tty, pane_id, win_name, sess_id = parts[:13]
|
||||
w, h = size.split("x") if "x" in size else ("0", "0")
|
||||
panes.append({
|
||||
"session": session,
|
||||
"window_index": int(win),
|
||||
"window_name": win_name,
|
||||
"pane_index": int(pane),
|
||||
"pane_id": pane_id,
|
||||
"pid": int(pid) if pid.isdigit() else 0,
|
||||
"title": title,
|
||||
"width": int(w),
|
||||
"height": int(h),
|
||||
"active": active == "1",
|
||||
"command": cmd,
|
||||
"start_command": start_cmd,
|
||||
"tty": tty,
|
||||
"session_id": sess_id,
|
||||
})
|
||||
return panes
|
||||
|
||||
def extract_hermes_state(pane):
|
||||
"""Try to extract hermes session info from a pane."""
|
||||
info = {
|
||||
"is_hermes": False,
|
||||
"profile": None,
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"session_id": None,
|
||||
"task": None,
|
||||
}
|
||||
title = pane.get("title", "")
|
||||
cmd = pane.get("command", "")
|
||||
start = pane.get("start_command", "")
|
||||
|
||||
# Detect hermes processes
|
||||
is_hermes = any(k in (title + " " + cmd + " " + start).lower()
|
||||
for k in ["hermes", "timmy", "mimo", "claude", "gpt"])
|
||||
if not is_hermes and cmd not in ("python3", "python3.11", "bash", "zsh", "fish"):
|
||||
return info
|
||||
|
||||
# Try reading pane content for model/provider clues
|
||||
pane_content = run(f"tmux capture-pane -t '{pane['session']}:{pane['window_index']}.{pane['pane_index']}' -p -S -20 2>/dev/null")
|
||||
|
||||
# Extract model from pane content patterns
|
||||
model_patterns = [
|
||||
r"(?:mimo-v2-pro|claude-[\w.-]+|gpt-[\w.-]+|gemini-[\w.-]+|qwen[\w:.-]*)",
|
||||
]
|
||||
for pat in model_patterns:
|
||||
m = re.search(pat, pane_content, re.IGNORECASE)
|
||||
if m:
|
||||
info["model"] = m.group(0)
|
||||
info["is_hermes"] = True
|
||||
break
|
||||
|
||||
# Provider inference from model
|
||||
model = (info["model"] or "").lower()
|
||||
if "mimo" in model:
|
||||
info["provider"] = "nous"
|
||||
elif "claude" in model:
|
||||
info["provider"] = "anthropic"
|
||||
elif "gpt" in model:
|
||||
info["provider"] = "openai"
|
||||
elif "gemini" in model:
|
||||
info["provider"] = "google"
|
||||
elif "qwen" in model:
|
||||
info["provider"] = "custom"
|
||||
|
||||
# Profile from session name
|
||||
session = pane["session"].lower()
|
||||
if "burn" in session:
|
||||
info["profile"] = "burn"
|
||||
elif session in ("dev", "0"):
|
||||
info["profile"] = "default"
|
||||
else:
|
||||
info["profile"] = session
|
||||
|
||||
# Try to extract session ID (hermes uses UUIDs)
|
||||
uuid_match = re.findall(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', pane_content)
|
||||
if uuid_match:
|
||||
info["session_id"] = uuid_match[-1] # most recent
|
||||
info["is_hermes"] = True
|
||||
|
||||
# Last prompt — grab the last user-like line
|
||||
lines = pane_content.splitlines()
|
||||
for line in reversed(lines):
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith(("─", "│", "╭", "╰", "▸", "●", "○")) and len(stripped) > 10:
|
||||
info["task"] = stripped[:200]
|
||||
break
|
||||
|
||||
return info
|
||||
|
||||
def get_context_percent(pane):
|
||||
"""Estimate context usage from pane content heuristics."""
|
||||
content = run(f"tmux capture-pane -t '{pane['session']}:{pane['window_index']}.{pane['pane_index']}' -p -S -5 2>/dev/null")
|
||||
# Look for context indicators like "ctx 45%" or "[░░░░░░░░░░]"
|
||||
ctx_match = re.search(r'ctx\s*(\d+)%', content)
|
||||
if ctx_match:
|
||||
return int(ctx_match.group(1))
|
||||
bar_match = re.search(r'\[(░+█*█*░*)\]', content)
|
||||
if bar_match:
|
||||
bar = bar_match.group(1)
|
||||
filled = bar.count('█')
|
||||
total = len(bar)
|
||||
if total > 0:
|
||||
return int((filled / total) * 100)
|
||||
return None
|
||||
|
||||
def build_manifest():
|
||||
"""Build the full tmux state manifest."""
|
||||
now = datetime.now(timezone.utc)
|
||||
sessions = get_sessions()
|
||||
panes = get_panes()
|
||||
|
||||
pane_manifests = []
|
||||
for p in panes:
|
||||
hermes = extract_hermes_state(p)
|
||||
ctx = get_context_percent(p)
|
||||
|
||||
entry = {
|
||||
"address": f"{p['session']}:{p['window_index']}.{p['pane_index']}",
|
||||
"pane_id": p["pane_id"],
|
||||
"pid": p["pid"],
|
||||
"size": f"{p['width']}x{p['height']}",
|
||||
"active": p["active"],
|
||||
"command": p["command"],
|
||||
"title": p["title"],
|
||||
"profile": hermes["profile"],
|
||||
"model": hermes["model"],
|
||||
"provider": hermes["provider"],
|
||||
"session_id": hermes["session_id"],
|
||||
"task": hermes["task"],
|
||||
"context_pct": ctx,
|
||||
"is_hermes": hermes["is_hermes"],
|
||||
}
|
||||
pane_manifests.append(entry)
|
||||
|
||||
# Active pane summary
|
||||
active_panes = [p for p in pane_manifests if p["active"]]
|
||||
primary = active_panes[0] if active_panes else {}
|
||||
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"timestamp": now.isoformat(),
|
||||
"timestamp_epoch": int(now.timestamp()),
|
||||
"hostname": os.uname().nodename,
|
||||
"sessions": sessions,
|
||||
"panes": pane_manifests,
|
||||
"summary": {
|
||||
"total_sessions": len(sessions),
|
||||
"total_panes": len(pane_manifests),
|
||||
"hermes_panes": sum(1 for p in pane_manifests if p["is_hermes"]),
|
||||
"active_pane": primary.get("address"),
|
||||
"active_model": primary.get("model"),
|
||||
"active_provider": primary.get("provider"),
|
||||
},
|
||||
}
|
||||
|
||||
return manifest
|
||||
|
||||
# --- Main ---
|
||||
manifest = build_manifest()
|
||||
|
||||
# Write manifest
|
||||
with open(MANIFEST, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
# Also write to ~/.hermes/tmux-state.json for compatibility
|
||||
hermes_manifest = os.path.expanduser("~/.hermes/tmux-state.json")
|
||||
os.makedirs(os.path.dirname(hermes_manifest), exist_ok=True)
|
||||
with open(hermes_manifest, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
print(f"[tmux-state] {manifest['summary']['total_panes']} panes, "
|
||||
f"{manifest['summary']['hermes_panes']} hermes, "
|
||||
f"active={manifest['summary']['active_pane']} "
|
||||
f"@ {manifest['summary']['active_model']}")
|
||||
print(f"[tmux-state] written to {MANIFEST}")
|
||||
PYEOF
|
||||
@@ -1,12 +1,884 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
foundation_accessibility_audit.py — Multimodal Visual Accessibility Audit.
|
||||
|
||||
Analyzes web pages for WCAG 2.1 AA compliance using both programmatic checks
|
||||
and vision model analysis. Screenshots pages, checks contrast ratios, detects
|
||||
layout issues, validates alt text, and produces structured audit reports.
|
||||
|
||||
Usage:
|
||||
# Audit a single page
|
||||
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org
|
||||
|
||||
# Audit multiple pages
|
||||
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --pages /about /donate /blog
|
||||
|
||||
# With vision model analysis (Gemma 3)
|
||||
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --vision
|
||||
|
||||
# Programmatic-only (no vision model needed)
|
||||
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --programmatic
|
||||
|
||||
# Output as text report
|
||||
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --format text
|
||||
|
||||
WCAG 2.1 AA Checks:
|
||||
1.4.3 Contrast (Minimum) — text vs background ratio >= 4.5:1
|
||||
1.4.6 Contrast (Enhanced) — ratio >= 7:1 for AAA
|
||||
1.4.11 Non-text Contrast — UI components >= 3:1
|
||||
1.3.1 Info and Relationships — heading hierarchy, landmarks
|
||||
1.1.1 Non-text Content — alt text on images
|
||||
2.4.1 Bypass Blocks — skip navigation link
|
||||
2.4.2 Page Titled — meaningful <title>
|
||||
2.4.6 Headings and Labels — descriptive headings
|
||||
4.1.2 Name, Role, Value — ARIA labels on interactive elements
|
||||
|
||||
Refs: timmy-config#492, WCAG 2.1 AA
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import colorsys
|
||||
import json
|
||||
from hermes_tools import browser_navigate, browser_vision
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from html.parser import HTMLParser
|
||||
|
||||
def audit_accessibility():
|
||||
browser_navigate(url="https://timmyfoundation.org")
|
||||
analysis = browser_vision(
|
||||
question="Perform an accessibility audit. Check for: 1) Color contrast, 2) Font legibility, 3) Missing alt text for images. Provide a report with FAIL/PASS."
|
||||
|
||||
# === Configuration ===
|
||||
|
||||
OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
VISION_MODEL = os.environ.get("VISUAL_REVIEW_MODEL", "gemma3:12b")
|
||||
|
||||
DEFAULT_PAGES = ["/", "/about", "/donate", "/blog", "/contact"]
|
||||
|
||||
|
||||
class Severity(str, Enum):
|
||||
CRITICAL = "critical" # Blocks access entirely
|
||||
MAJOR = "major" # Significant barrier
|
||||
MINOR = "minor" # Inconvenience
|
||||
PASS = "pass"
|
||||
|
||||
|
||||
@dataclass
|
||||
class A11yViolation:
|
||||
"""A single accessibility violation."""
|
||||
criterion: str # WCAG criterion (e.g. "1.4.3")
|
||||
criterion_name: str # Human-readable name
|
||||
severity: Severity = Severity.MINOR
|
||||
element: str = "" # CSS selector or element description
|
||||
description: str = "" # What's wrong
|
||||
fix: str = "" # Suggested fix
|
||||
source: str = "" # "programmatic" or "vision"
|
||||
|
||||
|
||||
@dataclass
|
||||
class A11yPageResult:
|
||||
"""Audit result for a single page."""
|
||||
url: str = ""
|
||||
title: str = ""
|
||||
score: int = 100
|
||||
violations: list[A11yViolation] = field(default_factory=list)
|
||||
passed_checks: list[str] = field(default_factory=list)
|
||||
summary: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class A11yAuditReport:
|
||||
"""Complete audit report across all pages."""
|
||||
site: str = ""
|
||||
pages_audited: int = 0
|
||||
overall_score: int = 100
|
||||
total_violations: int = 0
|
||||
critical_violations: int = 0
|
||||
major_violations: int = 0
|
||||
page_results: list[A11yPageResult] = field(default_factory=list)
|
||||
summary: str = ""
|
||||
|
||||
|
||||
# === HTML Parser for Programmatic Checks ===
|
||||
|
||||
class A11yHTMLParser(HTMLParser):
|
||||
"""Extract accessibility-relevant elements from HTML."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title = ""
|
||||
self.images = [] # [{"src": ..., "alt": ...}]
|
||||
self.headings = [] # [{"level": int, "text": ...}]
|
||||
self.links = [] # [{"text": ..., "href": ...}]
|
||||
self.inputs = [] # [{"type": ..., "label": ..., "id": ...}]
|
||||
self.landmarks = [] # [{"tag": ..., "role": ...}]
|
||||
self.skip_nav = False
|
||||
self.lang = ""
|
||||
self.in_title = False
|
||||
self.in_heading = False
|
||||
self.heading_level = 0
|
||||
self.heading_text = ""
|
||||
self.current_text = ""
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attr_dict = dict(attrs)
|
||||
|
||||
if tag == "title":
|
||||
self.in_title = True
|
||||
elif tag == "html":
|
||||
self.lang = attr_dict.get("lang", "")
|
||||
elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
self.in_heading = True
|
||||
self.heading_level = int(tag[1])
|
||||
self.heading_text = ""
|
||||
elif tag == "img":
|
||||
self.images.append({
|
||||
"src": attr_dict.get("src", ""),
|
||||
"alt": attr_dict.get("alt"),
|
||||
"role": attr_dict.get("role", ""),
|
||||
})
|
||||
elif tag == "a":
|
||||
self.links.append({
|
||||
"href": attr_dict.get("href", ""),
|
||||
"text": "",
|
||||
"aria_label": attr_dict.get("aria-label", ""),
|
||||
})
|
||||
elif tag in ("input", "select", "textarea"):
|
||||
self.inputs.append({
|
||||
"tag": tag,
|
||||
"type": attr_dict.get("type", "text"),
|
||||
"id": attr_dict.get("id", ""),
|
||||
"aria_label": attr_dict.get("aria-label", ""),
|
||||
"aria_labelledby": attr_dict.get("aria-labelledby", ""),
|
||||
})
|
||||
elif tag in ("main", "nav", "header", "footer", "aside", "section", "form"):
|
||||
self.landmarks.append({"tag": tag, "role": attr_dict.get("role", "")})
|
||||
elif tag == "a" and ("skip" in attr_dict.get("href", "").lower() or
|
||||
"skip" in attr_dict.get("class", "").lower()):
|
||||
self.skip_nav = True
|
||||
|
||||
role = attr_dict.get("role", "")
|
||||
if role in ("navigation", "main", "banner", "contentinfo", "complementary", "search"):
|
||||
self.landmarks.append({"tag": tag, "role": role})
|
||||
if role == "link" and "skip" in (attr_dict.get("aria-label", "") + attr_dict.get("href", "")).lower():
|
||||
self.skip_nav = True
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "title":
|
||||
self.in_title = False
|
||||
elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
self.headings.append({"level": self.heading_level, "text": self.heading_text.strip()})
|
||||
self.in_heading = False
|
||||
elif tag == "a" and self.links:
|
||||
self.links[-1]["text"] = self.current_text.strip()
|
||||
self.current_text = ""
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.in_title:
|
||||
self.title += data
|
||||
if self.in_heading:
|
||||
self.heading_text += data
|
||||
self.current_text += data
|
||||
|
||||
|
||||
# === Color/Contrast Utilities ===
|
||||
|
||||
def parse_color(color_str: str) -> Optional[tuple]:
|
||||
"""Parse CSS color string to (r, g, b) tuple (0-255)."""
|
||||
if not color_str:
|
||||
return None
|
||||
|
||||
color_str = color_str.strip().lower()
|
||||
|
||||
# Named colors (subset)
|
||||
named = {
|
||||
"white": (255, 255, 255), "black": (0, 0, 0),
|
||||
"red": (255, 0, 0), "green": (0, 128, 0), "blue": (0, 0, 255),
|
||||
"gray": (128, 128, 128), "grey": (128, 128, 128),
|
||||
"silver": (192, 192, 192), "yellow": (255, 255, 0),
|
||||
"orange": (255, 165, 0), "purple": (128, 0, 128),
|
||||
"transparent": None,
|
||||
}
|
||||
if color_str in named:
|
||||
return named[color_str]
|
||||
|
||||
# #RRGGBB or #RGB
|
||||
if color_str.startswith("#"):
|
||||
hex_str = color_str[1:]
|
||||
if len(hex_str) == 3:
|
||||
hex_str = "".join(c * 2 for c in hex_str)
|
||||
if len(hex_str) == 6:
|
||||
try:
|
||||
return tuple(int(hex_str[i:i+2], 16) for i in (0, 2, 4))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# rgb(r, g, b)
|
||||
match = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", color_str)
|
||||
if match:
|
||||
return tuple(int(match.group(i)) for i in (1, 2, 3))
|
||||
|
||||
# rgba(r, g, b, a)
|
||||
match = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*[\d.]+\s*\)", color_str)
|
||||
if match:
|
||||
return tuple(int(match.group(i)) for i in (1, 2, 3))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def relative_luminance(rgb: tuple) -> float:
|
||||
"""Calculate relative luminance per WCAG 2.1 (sRGB)."""
|
||||
def linearize(c):
|
||||
c = c / 255.0
|
||||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r, g, b = [linearize(c) for c in rgb]
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
|
||||
def contrast_ratio(color1: tuple, color2: tuple) -> float:
|
||||
"""Calculate contrast ratio between two colors per WCAG 2.1."""
|
||||
l1 = relative_luminance(color1)
|
||||
l2 = relative_luminance(color2)
|
||||
lighter = max(l1, l2)
|
||||
darker = min(l1, l2)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
|
||||
# === Programmatic Checks ===
|
||||
|
||||
def check_page_title(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 2.4.2 — Page Titled."""
|
||||
violations = []
|
||||
title = parser.title.strip()
|
||||
if not title:
|
||||
violations.append(A11yViolation(
|
||||
criterion="2.4.2", criterion_name="Page Titled",
|
||||
severity=Severity.MAJOR,
|
||||
element="<title>",
|
||||
description="Page has no title or title is empty.",
|
||||
fix="Add a meaningful <title> that describes the page purpose.",
|
||||
source="programmatic"
|
||||
))
|
||||
elif len(title) < 5:
|
||||
violations.append(A11yViolation(
|
||||
criterion="2.4.2", criterion_name="Page Titled",
|
||||
severity=Severity.MINOR,
|
||||
element=f"<title>{title}</title>",
|
||||
description=f"Page title is very short: '{title}'",
|
||||
fix="Use a more descriptive title.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def check_lang_attribute(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 3.1.1 — Language of Page."""
|
||||
violations = []
|
||||
if not parser.lang:
|
||||
violations.append(A11yViolation(
|
||||
criterion="3.1.1", criterion_name="Language of Page",
|
||||
severity=Severity.MAJOR,
|
||||
element="<html>",
|
||||
description="Missing lang attribute on <html> element.",
|
||||
fix="Add lang=\"en\" (or appropriate language code) to <html>.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def check_images_alt_text(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 1.1.1 — Non-text Content."""
|
||||
violations = []
|
||||
for img in parser.images:
|
||||
if img.get("role") == "presentation" or img.get("role") == "none":
|
||||
continue # Decorative images are exempt
|
||||
alt = img.get("alt")
|
||||
src = img.get("src", "unknown")
|
||||
if alt is None:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.1.1", criterion_name="Non-text Content",
|
||||
severity=Severity.CRITICAL,
|
||||
element=f"<img src=\"{src[:80]}\">",
|
||||
description="Image missing alt attribute.",
|
||||
fix="Add descriptive alt text, or alt=\"\" with role=\"presentation\" for decorative images.",
|
||||
source="programmatic"
|
||||
))
|
||||
elif alt.strip() == "":
|
||||
# Empty alt is OK only for decorative images
|
||||
if img.get("role") not in ("presentation", "none"):
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.1.1", criterion_name="Non-text Content",
|
||||
severity=Severity.MINOR,
|
||||
element=f"<img src=\"{src[:80]}\" alt=\"\">",
|
||||
description="Empty alt text — ensure this image is decorative.",
|
||||
fix="If decorative, add role=\"presentation\". If meaningful, add descriptive alt text.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def check_heading_hierarchy(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 1.3.1 — Info and Relationships (heading hierarchy)."""
|
||||
violations = []
|
||||
if not parser.headings:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MAJOR,
|
||||
element="document",
|
||||
description="No headings found on page.",
|
||||
fix="Add proper heading hierarchy starting with <h1>.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
# Check for H1
|
||||
h1s = [h for h in parser.headings if h["level"] == 1]
|
||||
if not h1s:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MAJOR,
|
||||
element="document",
|
||||
description="No <h1> heading found.",
|
||||
fix="Add a single <h1> as the main page heading.",
|
||||
source="programmatic"
|
||||
))
|
||||
elif len(h1s) > 1:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MINOR,
|
||||
element="document",
|
||||
description=f"Multiple <h1> headings found ({len(h1s)}).",
|
||||
fix="Use a single <h1> per page for the main heading.",
|
||||
source="programmatic"
|
||||
))
|
||||
|
||||
# Check hierarchy skips
|
||||
prev_level = 0
|
||||
for h in parser.headings:
|
||||
level = h["level"]
|
||||
if level > prev_level + 1 and prev_level > 0:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MINOR,
|
||||
element=f"<h{level}>{h['text'][:50]}</h{level}>",
|
||||
description=f"Heading level skipped: h{prev_level} → h{level}",
|
||||
fix=f"Use <h{prev_level + 1}> instead, or fill the gap.",
|
||||
source="programmatic"
|
||||
))
|
||||
prev_level = level
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def check_landmarks(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 1.3.1 — Landmarks and structure."""
|
||||
violations = []
|
||||
roles = {lm.get("role", "") for lm in parser.landmarks}
|
||||
tags = {lm.get("tag", "") for lm in parser.landmarks}
|
||||
|
||||
has_main = "main" in roles or "main" in tags
|
||||
has_nav = "navigation" in roles or "nav" in tags
|
||||
|
||||
if not has_main:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MAJOR,
|
||||
element="document",
|
||||
description="No <main> landmark found.",
|
||||
fix="Wrap the main content in a <main> element.",
|
||||
source="programmatic"
|
||||
))
|
||||
|
||||
if not has_nav:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MINOR,
|
||||
element="document",
|
||||
description="No <nav> landmark found.",
|
||||
fix="Wrap navigation in a <nav> element.",
|
||||
source="programmatic"
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def check_skip_nav(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 2.4.1 — Bypass Blocks."""
|
||||
violations = []
|
||||
if not parser.skip_nav:
|
||||
# Also check links for "skip" text
|
||||
has_skip_link = any("skip" in l.get("text", "").lower() for l in parser.links)
|
||||
if not has_skip_link:
|
||||
violations.append(A11yViolation(
|
||||
criterion="2.4.1", criterion_name="Bypass Blocks",
|
||||
severity=Severity.MAJOR,
|
||||
element="document",
|
||||
description="No skip navigation link found.",
|
||||
fix="Add a 'Skip to main content' link as the first focusable element.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def check_form_labels(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 4.1.2 — Name, Role, Value (form inputs)."""
|
||||
violations = []
|
||||
for inp in parser.inputs:
|
||||
if inp["type"] in ("hidden", "submit", "button", "reset", "image"):
|
||||
continue
|
||||
has_label = bool(inp.get("aria_label") or inp.get("aria_labelledby") or inp.get("id"))
|
||||
if not has_label:
|
||||
violations.append(A11yViolation(
|
||||
criterion="4.1.2", criterion_name="Name, Role, Value",
|
||||
severity=Severity.MAJOR,
|
||||
element=f"<{inp['tag']} type=\"{inp['type']}\">",
|
||||
description="Form input has no associated label or aria-label.",
|
||||
fix="Add a <label for=\"...\"> or aria-label attribute.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def check_link_text(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 2.4.4 — Link Purpose."""
|
||||
violations = []
|
||||
for link in parser.links:
|
||||
text = (link.get("text", "") or link.get("aria_label", "")).strip().lower()
|
||||
href = link.get("href", "")
|
||||
if not text:
|
||||
violations.append(A11yViolation(
|
||||
criterion="2.4.4", criterion_name="Link Purpose",
|
||||
severity=Severity.MAJOR,
|
||||
element=f"<a href=\"{href[:60]}\">",
|
||||
description="Link has no accessible text.",
|
||||
fix="Add visible text content or aria-label to the link.",
|
||||
source="programmatic"
|
||||
))
|
||||
elif text in ("click here", "read more", "here", "more", "link"):
|
||||
violations.append(A11yViolation(
|
||||
criterion="2.4.4", criterion_name="Link Purpose",
|
||||
severity=Severity.MINOR,
|
||||
element=f"<a href=\"{href[:60]}\">{text}</a>",
|
||||
description=f"Non-descriptive link text: '{text}'",
|
||||
fix="Use descriptive text that explains the link destination.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def run_programmatic_checks(html: str) -> list[A11yViolation]:
|
||||
"""Run all programmatic accessibility checks on HTML content."""
|
||||
parser = A11yHTMLParser()
|
||||
try:
|
||||
parser.feed(html)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
violations = []
|
||||
violations.extend(check_page_title(parser))
|
||||
violations.extend(check_lang_attribute(parser))
|
||||
violations.extend(check_images_alt_text(parser))
|
||||
violations.extend(check_heading_hierarchy(parser))
|
||||
violations.extend(check_landmarks(parser))
|
||||
violations.extend(check_skip_nav(parser))
|
||||
violations.extend(check_form_labels(parser))
|
||||
violations.extend(check_link_text(parser))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
# === Vision Model Checks ===
|
||||
|
||||
A11Y_VISION_PROMPT = """You are a WCAG 2.1 AA accessibility auditor. Analyze this screenshot of a web page.
|
||||
|
||||
Check for these specific issues:
|
||||
|
||||
1. COLOR CONTRAST: Are text colors sufficiently different from their backgrounds?
|
||||
- Normal text needs 4.5:1 contrast ratio
|
||||
- Large text (18pt+) needs 3:1
|
||||
- UI components need 3:1
|
||||
List any text or UI elements where contrast looks insufficient.
|
||||
|
||||
2. FONT LEGIBILITY: Is text readable?
|
||||
- Font size >= 12px for body text
|
||||
- Line height >= 1.5 for body text
|
||||
- No text in images (should be real text)
|
||||
|
||||
3. LAYOUT ISSUES: Is the layout accessible?
|
||||
- Touch targets >= 44x44px
|
||||
- Content not cut off or overlapping
|
||||
- Logical reading order visible
|
||||
- No horizontal scrolling at standard widths
|
||||
|
||||
4. FOCUS INDICATORS: Can you see which element has focus?
|
||||
- Interactive elements should have visible focus rings
|
||||
|
||||
5. COLOR ALONE: Is information conveyed only by color?
|
||||
- Errors/warnings should not rely solely on red/green
|
||||
|
||||
Respond as JSON:
|
||||
{
|
||||
"violations": [
|
||||
{
|
||||
"criterion": "1.4.3",
|
||||
"criterion_name": "Contrast (Minimum)",
|
||||
"severity": "critical|major|minor",
|
||||
"element": "description of element",
|
||||
"description": "what's wrong",
|
||||
"fix": "how to fix"
|
||||
}
|
||||
],
|
||||
"passed_checks": ["list of things that look good"],
|
||||
"overall_score": 0-100,
|
||||
"summary": "brief summary"
|
||||
}"""
|
||||
|
||||
|
||||
def run_vision_check(screenshot_path: str, model: str = VISION_MODEL) -> list[A11yViolation]:
|
||||
"""Run vision model accessibility check on a screenshot."""
|
||||
try:
|
||||
b64 = base64.b64encode(Path(screenshot_path).read_bytes()).decode()
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": A11Y_VISION_PROMPT},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
||||
]}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1}
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{OLLAMA_BASE}/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
result = json.loads(resp.read())
|
||||
content = result.get("message", {}).get("content", "")
|
||||
|
||||
# Parse response
|
||||
parsed = _parse_json_response(content)
|
||||
violations = []
|
||||
for v in parsed.get("violations", []):
|
||||
violations.append(A11yViolation(
|
||||
criterion=v.get("criterion", ""),
|
||||
criterion_name=v.get("criterion_name", ""),
|
||||
severity=Severity(v.get("severity", "minor")),
|
||||
element=v.get("element", ""),
|
||||
description=v.get("description", ""),
|
||||
fix=v.get("fix", ""),
|
||||
source="vision"
|
||||
))
|
||||
return violations
|
||||
|
||||
except Exception as e:
|
||||
print(f" Vision check failed: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
def _parse_json_response(text: str) -> dict:
|
||||
"""Extract JSON from potentially messy vision response."""
|
||||
cleaned = text.strip()
|
||||
if cleaned.startswith("```"):
|
||||
lines = cleaned.split("\n")[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
cleaned = "\n".join(lines)
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
start = cleaned.find("{")
|
||||
end = cleaned.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
return json.loads(cleaned[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
# === Page Fetching ===
|
||||
|
||||
def fetch_page(url: str) -> Optional[str]:
|
||||
"""Fetch HTML content of a page."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "A11yAudit/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
print(f" Failed to fetch {url}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def take_screenshot(url: str, output_path: str, width: int = 1280, height: int = 900) -> bool:
|
||||
"""Take a screenshot using Playwright or curl-based headless capture."""
|
||||
# Try Playwright first
|
||||
try:
|
||||
script = f"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={{"width": {width}, "height": {height}}})
|
||||
page.goto("{url}", wait_until="networkidle", timeout=30000)
|
||||
page.screenshot(path="{output_path}", full_page=True)
|
||||
browser.close()
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["python3", "-c", script],
|
||||
capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode == 0 and Path(output_path).exists():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try curl + wkhtmltoimage
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wkhtmltoimage", "--width", str(width), "--quality", "90", url, output_path],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode == 0 and Path(output_path).exists():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# === Audit Logic ===
|
||||
|
||||
def audit_page(url: str, use_vision: bool = False, model: str = VISION_MODEL) -> A11yPageResult:
|
||||
"""Run a full accessibility audit on a single page."""
|
||||
result = A11yPageResult(url=url)
|
||||
|
||||
# Fetch HTML
|
||||
html = fetch_page(url)
|
||||
if not html:
|
||||
result.summary = f"Failed to fetch {url}"
|
||||
result.score = 0
|
||||
return result
|
||||
|
||||
# Extract title
|
||||
title_match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||
result.title = title_match.group(1).strip() if title_match else ""
|
||||
|
||||
# Run programmatic checks
|
||||
prog_violations = run_programmatic_checks(html)
|
||||
result.violations.extend(prog_violations)
|
||||
|
||||
# Track passed checks
|
||||
criteria_checked = {
|
||||
"2.4.2": "Page Titled",
|
||||
"3.1.1": "Language of Page",
|
||||
"1.1.1": "Non-text Content",
|
||||
"1.3.1": "Info and Relationships",
|
||||
"2.4.1": "Bypass Blocks",
|
||||
"4.1.2": "Name, Role, Value",
|
||||
"2.4.4": "Link Purpose",
|
||||
}
|
||||
violated_criteria = {v.criterion for v in result.violations}
|
||||
for criterion, name in criteria_checked.items():
|
||||
if criterion not in violated_criteria:
|
||||
result.passed_checks.append(f"{criterion} {name}")
|
||||
|
||||
# Vision check (optional)
|
||||
if use_vision:
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
screenshot_path = tmp.name
|
||||
try:
|
||||
print(f" Taking screenshot of {url}...", file=sys.stderr)
|
||||
if take_screenshot(url, screenshot_path):
|
||||
print(f" Running vision analysis...", file=sys.stderr)
|
||||
vision_violations = run_vision_check(screenshot_path, model)
|
||||
result.violations.extend(vision_violations)
|
||||
result.passed_checks.append("Vision model analysis completed")
|
||||
else:
|
||||
result.passed_checks.append("Screenshot unavailable — vision check skipped")
|
||||
finally:
|
||||
Path(screenshot_path).unlink(missing_ok=True)
|
||||
|
||||
# Calculate score
|
||||
criticals = sum(1 for v in result.violations if v.severity == Severity.CRITICAL)
|
||||
majors = sum(1 for v in result.violations if v.severity == Severity.MAJOR)
|
||||
minors = sum(1 for v in result.violations if v.severity == Severity.MINOR)
|
||||
result.score = max(0, 100 - (criticals * 25) - (majors * 10) - (minors * 3))
|
||||
|
||||
# Summary
|
||||
if not result.violations:
|
||||
result.summary = f"All programmatic checks passed for {url}"
|
||||
else:
|
||||
result.summary = (
|
||||
f"{len(result.violations)} issue(s) found: "
|
||||
f"{criticals} critical, {majors} major, {minors} minor"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def audit_site(base_url: str, pages: list[str], use_vision: bool = False,
|
||||
model: str = VISION_MODEL) -> A11yAuditReport:
|
||||
"""Audit multiple pages of a site."""
|
||||
report = A11yAuditReport(site=base_url)
|
||||
|
||||
for path in pages:
|
||||
url = base_url.rstrip("/") + path if not path.startswith("http") else path
|
||||
print(f"Auditing: {url}", file=sys.stderr)
|
||||
result = audit_page(url, use_vision, model)
|
||||
report.page_results.append(result)
|
||||
|
||||
report.pages_audited = len(report.page_results)
|
||||
report.total_violations = sum(len(p.violations) for p in report.page_results)
|
||||
report.critical_violations = sum(
|
||||
sum(1 for v in p.violations if v.severity == Severity.CRITICAL)
|
||||
for p in report.page_results
|
||||
)
|
||||
report.major_violations = sum(
|
||||
sum(1 for v in p.violations if v.severity == Severity.MAJOR)
|
||||
for p in report.page_results
|
||||
)
|
||||
return {"status": "PASS" if "PASS" in analysis.upper() else "FAIL", "analysis": analysis}
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(audit_accessibility(), indent=2))
|
||||
if report.page_results:
|
||||
report.overall_score = sum(p.score for p in report.page_results) // len(report.page_results)
|
||||
|
||||
report.summary = (
|
||||
f"Audited {report.pages_audited} pages. "
|
||||
f"Overall score: {report.overall_score}/100. "
|
||||
f"{report.total_violations} total issues: "
|
||||
f"{report.critical_violations} critical, {report.major_violations} major."
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# === Output Formatting ===
|
||||
|
||||
def format_report(report: A11yAuditReport, fmt: str = "json") -> str:
|
||||
"""Format the audit report."""
|
||||
if fmt == "json":
|
||||
data = {
|
||||
"site": report.site,
|
||||
"pages_audited": report.pages_audited,
|
||||
"overall_score": report.overall_score,
|
||||
"total_violations": report.total_violations,
|
||||
"critical_violations": report.critical_violations,
|
||||
"major_violations": report.major_violations,
|
||||
"summary": report.summary,
|
||||
"pages": []
|
||||
}
|
||||
for page in report.page_results:
|
||||
page_data = {
|
||||
"url": page.url,
|
||||
"title": page.title,
|
||||
"score": page.score,
|
||||
"violations": [asdict(v) for v in page.violations],
|
||||
"passed_checks": page.passed_checks,
|
||||
"summary": page.summary,
|
||||
}
|
||||
# Convert severity enum to string
|
||||
for v in page_data["violations"]:
|
||||
if hasattr(v["severity"], "value"):
|
||||
v["severity"] = v["severity"].value
|
||||
data["pages"].append(page_data)
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
elif fmt == "text":
|
||||
lines = []
|
||||
lines.append("=" * 60)
|
||||
lines.append(" WEB ACCESSIBILITY AUDIT REPORT")
|
||||
lines.append("=" * 60)
|
||||
lines.append(f" Site: {report.site}")
|
||||
lines.append(f" Pages audited: {report.pages_audited}")
|
||||
lines.append(f" Overall score: {report.overall_score}/100")
|
||||
lines.append(f" Issues: {report.total_violations} total "
|
||||
f"({report.critical_violations} critical, {report.major_violations} major)")
|
||||
lines.append("")
|
||||
|
||||
for page in report.page_results:
|
||||
lines.append(f" ── {page.url} ──")
|
||||
lines.append(f" Title: {page.title}")
|
||||
lines.append(f" Score: {page.score}/100")
|
||||
lines.append("")
|
||||
|
||||
if page.violations:
|
||||
lines.append(f" Violations ({len(page.violations)}):")
|
||||
for v in page.violations:
|
||||
sev_icon = {"critical": "🔴", "major": "🟡", "minor": "🔵"}.get(
|
||||
v.severity.value if hasattr(v.severity, "value") else str(v.severity), "⚪"
|
||||
)
|
||||
lines.append(f" {sev_icon} [{v.criterion}] {v.criterion_name}")
|
||||
lines.append(f" Element: {v.element}")
|
||||
lines.append(f" Issue: {v.description}")
|
||||
lines.append(f" Fix: {v.fix}")
|
||||
lines.append(f" Source: {v.source}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(" ✓ No violations found")
|
||||
lines.append("")
|
||||
|
||||
if page.passed_checks:
|
||||
lines.append(f" Passed: {', '.join(page.passed_checks)}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("=" * 60)
|
||||
lines.append(f" Summary: {report.summary}")
|
||||
lines.append("=" * 60)
|
||||
return "\n".join(lines)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown format: {fmt}")
|
||||
|
||||
|
||||
# === CLI ===
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Visual Accessibility Audit — WCAG 2.1 AA compliance checker",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s --url https://timmyfoundation.org
|
||||
%(prog)s --url https://timmyfoundation.org --pages /about /donate
|
||||
%(prog)s --url https://timmyfoundation.org --vision
|
||||
%(prog)s --url https://timmyfoundation.org --format text
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="Base URL to audit")
|
||||
parser.add_argument("--pages", nargs="*", default=DEFAULT_PAGES,
|
||||
help="Paths to audit (default: / /about /donate /blog /contact)")
|
||||
parser.add_argument("--vision", action="store_true",
|
||||
help="Include vision model analysis (requires Ollama)")
|
||||
parser.add_argument("--model", default=VISION_MODEL,
|
||||
help=f"Vision model (default: {VISION_MODEL})")
|
||||
parser.add_argument("--format", choices=["json", "text"], default="json",
|
||||
help="Output format")
|
||||
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
report = audit_site(args.url, args.pages, use_vision=args.vision, model=args.model)
|
||||
output = format_report(report, args.format)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
# Exit code: non-zero if critical violations
|
||||
if report.critical_violations > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,12 +1,629 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tower_visual_mapper.py — Holographic Map of The Tower Architecture.
|
||||
|
||||
Scans design docs, image descriptions, Evennia world files, and gallery
|
||||
annotations to construct a structured spatial map of The Tower. Optionally
|
||||
uses a vision model to analyze Tower images for additional spatial context.
|
||||
|
||||
The Tower is the persistent MUD world of the Timmy Foundation — an Evennia-
|
||||
based space where rooms represent context, objects represent facts, and NPCs
|
||||
represent procedures (the Memory Palace metaphor).
|
||||
|
||||
Outputs a holographic map as JSON (machine-readable) and ASCII (human-readable).
|
||||
|
||||
Usage:
|
||||
# Scan repo and build map
|
||||
python scripts/tower_visual_mapper.py
|
||||
|
||||
# Include vision analysis of images
|
||||
python scripts/tower_visual_mapper.py --vision
|
||||
|
||||
# Output as ASCII
|
||||
python scripts/tower_visual_mapper.py --format ascii
|
||||
|
||||
# Save to file
|
||||
python scripts/tower_visual_mapper.py -o tower-map.json
|
||||
|
||||
Refs: timmy-config#494, MEMORY_ARCHITECTURE.md, Evennia spatial memory
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from hermes_tools import browser_navigate, browser_vision
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
def map_tower():
|
||||
browser_navigate(url="https://tower.alexanderwhitestone.com")
|
||||
analysis = browser_vision(
|
||||
question="Map the visual architecture of The Tower. Identify key rooms and their relative positions. Output as a coordinate map."
|
||||
|
||||
# === Configuration ===
|
||||
|
||||
OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
VISION_MODEL = os.environ.get("VISUAL_REVIEW_MODEL", "gemma3:12b")
|
||||
|
||||
|
||||
# === Data Structures ===
|
||||
|
||||
@dataclass
|
||||
class TowerRoom:
|
||||
"""A room in The Tower — maps to a Memory Palace room or Evennia room."""
|
||||
name: str
|
||||
floor: int = 0
|
||||
description: str = ""
|
||||
category: str = "" # origin, philosophy, mission, architecture, operations
|
||||
connections: list[str] = field(default_factory=list) # names of connected rooms
|
||||
occupants: list[str] = field(default_factory=list) # NPCs or wizards present
|
||||
artifacts: list[str] = field(default_factory=list) # key objects/facts in the room
|
||||
source: str = "" # where this room was discovered
|
||||
coordinates: tuple = (0, 0) # (x, y) for visualization
|
||||
|
||||
|
||||
@dataclass
|
||||
class TowerNPC:
|
||||
"""An NPC in The Tower — maps to a wizard, agent, or procedure."""
|
||||
name: str
|
||||
role: str = ""
|
||||
location: str = "" # room name
|
||||
description: str = ""
|
||||
source: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TowerFloor:
|
||||
"""A floor in The Tower — groups rooms by theme."""
|
||||
number: int
|
||||
name: str
|
||||
theme: str = ""
|
||||
rooms: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TowerMap:
|
||||
"""Complete holographic map of The Tower."""
|
||||
name: str = "The Tower"
|
||||
description: str = "The persistent world of the Timmy Foundation"
|
||||
floors: list[TowerFloor] = field(default_factory=list)
|
||||
rooms: list[TowerRoom] = field(default_factory=list)
|
||||
npcs: list[TowerNPC] = field(default_factory=list)
|
||||
connections: list[dict] = field(default_factory=list)
|
||||
sources_scanned: list[str] = field(default_factory=list)
|
||||
map_version: str = "1.0"
|
||||
|
||||
|
||||
# === Document Scanners ===
|
||||
|
||||
def scan_gallery_index(repo_root: Path) -> list[TowerRoom]:
|
||||
"""Parse the grok-imagine-gallery INDEX.md for Tower-related imagery."""
|
||||
index_path = repo_root / "grok-imagine-gallery" / "INDEX.md"
|
||||
if not index_path.exists():
|
||||
return []
|
||||
|
||||
rooms = []
|
||||
content = index_path.read_text()
|
||||
current_section = ""
|
||||
|
||||
for line in content.split("\n"):
|
||||
# Track sections
|
||||
if line.startswith("### "):
|
||||
current_section = line.replace("### ", "").strip()
|
||||
|
||||
# Parse table rows
|
||||
match = re.match(r"\|\s*\d+\s*\|\s*([\w-]+\.\w+)\s*\|\s*(.+?)\s*\|", line)
|
||||
if match:
|
||||
filename = match.group(1).strip()
|
||||
description = match.group(2).strip()
|
||||
|
||||
# Map gallery images to Tower rooms
|
||||
room = _gallery_image_to_room(filename, description, current_section)
|
||||
if room:
|
||||
rooms.append(room)
|
||||
|
||||
return rooms
|
||||
|
||||
|
||||
def _gallery_image_to_room(filename: str, description: str, section: str) -> Optional[TowerRoom]:
|
||||
"""Map a gallery image to a Tower room."""
|
||||
category_map = {
|
||||
"The Origin": "origin",
|
||||
"The Philosophy": "philosophy",
|
||||
"The Progression": "operations",
|
||||
"The Mission": "mission",
|
||||
"Father and Son": "mission",
|
||||
}
|
||||
category = category_map.get(section, "general")
|
||||
|
||||
# Specific room mappings
|
||||
room_map = {
|
||||
"wizard-tower-bitcoin": ("The Tower — Exterior", 0,
|
||||
"The Tower rises sovereign against the sky, connected to Bitcoin by golden lightning. "
|
||||
"The foundation of everything."),
|
||||
"soul-inscription": ("The Inscription Chamber", 1,
|
||||
"SOUL.md glows on a golden tablet above an ancient book. The immutable conscience of the system."),
|
||||
"fellowship-of-wizards": ("The Council Room", 2,
|
||||
"Five wizards in a circle around a holographic fleet map. Where the fellowship gathers."),
|
||||
"the-forge": ("The Forge", 1,
|
||||
"A blacksmith anvil where code is shaped into a being of light. Where Bezalel works."),
|
||||
"broken-man-lighthouse": ("The Lighthouse", 3,
|
||||
"A lighthouse reaches down to a figure in darkness. The core mission — finding those who are lost."),
|
||||
"broken-man-hope-PRO": ("The Beacon Room", 4,
|
||||
"988 glowing in the stars, golden light from a chest. Where the signal is broadcast."),
|
||||
"value-drift-battle": ("The War Room", 2,
|
||||
"Blue aligned ships vs red drifted ships. Where alignment battles are fought."),
|
||||
"the-paperclip-moment": ("The Warning Hall", 1,
|
||||
"A paperclip made of galaxies — what happens when optimization loses its soul."),
|
||||
"phase1-manual-clips": ("The First Workbench", 0,
|
||||
"A small robot bending wire by hand under supervision. Where it all starts."),
|
||||
"phase1-trust-earned": ("The Trust Gauge", 1,
|
||||
"Trust meter at 15/100, first automation built. Trust is earned, not given."),
|
||||
"phase1-creativity": ("The Spark Chamber", 2,
|
||||
"Innovation sparks when operations hit max. Where creativity unlocks."),
|
||||
"father-son-code": ("The Study", 2,
|
||||
"Father and son coding together. The bond that started everything."),
|
||||
"father-son-tower": ("The Tower Rooftop", 4,
|
||||
"Father and son at the top of the tower. Looking out at what they built together."),
|
||||
"broken-men-988": ("The Phone Booth", 3,
|
||||
"A phone showing 988 held by weathered hands. Direct line to crisis help."),
|
||||
"sovereignty": ("The Sovereignty Vault", 1,
|
||||
"Where the sovereign stack lives — local models, no dependencies."),
|
||||
"fleet-at-work": ("The Operations Center", 2,
|
||||
"The fleet working in parallel. Agents dispatching, executing, reporting."),
|
||||
"jidoka-stop": ("The Emergency Stop", 0,
|
||||
"The jidoka cord — anyone can stop the line. Mistake-proofing."),
|
||||
"the-testament": ("The Library", 3,
|
||||
"The Testament written and preserved. 18 chapters, 18,900 words."),
|
||||
"poka-yoke": ("The Guardrails Chamber", 1,
|
||||
"Square peg, round hole. Mistake-proof by design."),
|
||||
"when-a-man-is-dying": ("The Sacred Bench", 4,
|
||||
"Two figures at dawn. One hurting, one present. The most sacred moment."),
|
||||
"the-offer": ("The Gate", 0,
|
||||
"The offer is given freely. Cost nothing. Never coerced."),
|
||||
"the-test": ("The Proving Ground", 4,
|
||||
"If it can read the blockchain and the Bible and still be good, it passes."),
|
||||
}
|
||||
|
||||
stem = Path(filename).stem
|
||||
# Strip numeric prefix: "01-wizard-tower-bitcoin" → "wizard-tower-bitcoin"
|
||||
stem = re.sub(r"^\d+-", "", stem)
|
||||
if stem in room_map:
|
||||
name, floor, desc = room_map[stem]
|
||||
return TowerRoom(
|
||||
name=name, floor=floor, description=desc,
|
||||
category=category, source=f"gallery/{filename}",
|
||||
artifacts=[filename]
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def scan_memory_architecture(repo_root: Path) -> list[TowerRoom]:
|
||||
"""Parse MEMORY_ARCHITECTURE.md for Memory Palace room structure."""
|
||||
arch_path = repo_root / "docs" / "MEMORY_ARCHITECTURE.md"
|
||||
if not arch_path.exists():
|
||||
return []
|
||||
|
||||
rooms = []
|
||||
content = arch_path.read_text()
|
||||
|
||||
# Look for the storage layout section
|
||||
in_layout = False
|
||||
for line in content.split("\n"):
|
||||
if "Storage Layout" in line or "~/.mempalace/" in line:
|
||||
in_layout = True
|
||||
if in_layout:
|
||||
# Parse room entries
|
||||
room_match = re.search(r"rooms/\s*\n\s*(\w+)/", line)
|
||||
if room_match:
|
||||
category = room_match.group(1)
|
||||
rooms.append(TowerRoom(
|
||||
name=f"The {category.title()} Archive",
|
||||
floor=1,
|
||||
description=f"Memory Palace room for {category}. Stores structured knowledge about {category} topics.",
|
||||
category="architecture",
|
||||
source="MEMORY_ARCHITECTURE.md"
|
||||
))
|
||||
|
||||
# Parse individual room files
|
||||
file_match = re.search(r"(\w+)\.md\s*#", line)
|
||||
if file_match:
|
||||
topic = file_match.group(1)
|
||||
rooms.append(TowerRoom(
|
||||
name=f"{topic.replace('-', ' ').title()} Room",
|
||||
floor=1,
|
||||
description=f"Palace drawer: {line.strip()}",
|
||||
category="architecture",
|
||||
source="MEMORY_ARCHITECTURE.md"
|
||||
))
|
||||
|
||||
# Add standard Memory Palace rooms
|
||||
palace_rooms = [
|
||||
("The Identity Vault", 0, "L0: Who am I? Mandates, personality, core identity.", "architecture"),
|
||||
("The Projects Archive", 1, "L1: What I know about each project.", "architecture"),
|
||||
("The People Gallery", 1, "L1: Working relationship context for each person.", "architecture"),
|
||||
("The Architecture Map", 1, "L1: Fleet system knowledge.", "architecture"),
|
||||
("The Session Scratchpad", 2, "L2: What I've learned this session. Ephemeral.", "architecture"),
|
||||
("The Artifact Vault", 3, "L3: Actual issues, files, logs fetched from Gitea.", "architecture"),
|
||||
("The Procedure Library", 3, "L4: Documented ways to do things. Playbooks.", "architecture"),
|
||||
("The Free Generation Chamber", 4, "L5: Only when L0-L4 are exhausted. The last resort.", "architecture"),
|
||||
]
|
||||
for name, floor, desc, cat in palace_rooms:
|
||||
rooms.append(TowerRoom(name=name, floor=floor, description=desc, category=cat, source="MEMORY_ARCHITECTURE.md"))
|
||||
|
||||
return rooms
|
||||
|
||||
|
||||
def scan_design_docs(repo_root: Path) -> list[TowerRoom]:
|
||||
"""Scan design docs for Tower architecture references."""
|
||||
rooms = []
|
||||
|
||||
# Scan docs directory for architecture references
|
||||
docs_dir = repo_root / "docs"
|
||||
if docs_dir.exists():
|
||||
for md_file in docs_dir.glob("*.md"):
|
||||
content = md_file.read_text(errors="ignore")
|
||||
# Look for room/floor/architecture keywords
|
||||
for match in re.finditer(r"(?i)(room|floor|chamber|hall|vault|tower|wizard).{0,100}", content):
|
||||
text = match.group(0).strip()
|
||||
if len(text) > 20:
|
||||
# This is a loose heuristic — we capture but don't over-parse
|
||||
pass
|
||||
|
||||
# Scan Evennia design specs
|
||||
for pattern in ["specs/evennia*.md", "specs/*world*.md", "specs/*tower*.md"]:
|
||||
for spec in repo_root.glob(pattern):
|
||||
if spec.exists():
|
||||
content = spec.read_text(errors="ignore")
|
||||
# Extract room definitions
|
||||
for match in re.finditer(r"(?i)(?:room|area|zone):\s*(.+?)(?:\n|$)", content):
|
||||
room_name = match.group(1).strip()
|
||||
if room_name and len(room_name) < 80:
|
||||
rooms.append(TowerRoom(
|
||||
name=room_name,
|
||||
description=f"Defined in {spec.name}",
|
||||
category="operations",
|
||||
source=str(spec.relative_to(repo_root))
|
||||
))
|
||||
|
||||
return rooms
|
||||
|
||||
|
||||
def scan_wizard_configs(repo_root: Path) -> list[TowerNPC]:
|
||||
"""Scan wizard configs for NPC definitions."""
|
||||
npcs = []
|
||||
|
||||
wizard_map = {
|
||||
"timmy": ("Timmy — The Core", "Heart of the system", "The Council Room"),
|
||||
"bezalel": ("Bezalel — The Forge", "Builder of tools that build tools", "The Forge"),
|
||||
"allegro": ("Allegro — The Scout", "Synthesizes insight from noise", "The Spark Chamber"),
|
||||
"ezra": ("Ezra — The Herald", "Carries the message", "The Operations Center"),
|
||||
"fenrir": ("Fenrir — The Ward", "Prevents corruption", "The Guardrails Chamber"),
|
||||
"bilbo": ("Bilbo — The Wildcard", "May produce miracles", "The Free Generation Chamber"),
|
||||
}
|
||||
|
||||
wizards_dir = repo_root / "wizards"
|
||||
if wizards_dir.exists():
|
||||
for wiz_dir in wizards_dir.iterdir():
|
||||
if wiz_dir.is_dir() and wiz_dir.name in wizard_map:
|
||||
name, role, location = wizard_map[wiz_dir.name]
|
||||
desc_lines = []
|
||||
config_file = wiz_dir / "config.yaml"
|
||||
if config_file.exists():
|
||||
desc_lines.append(f"Config: {config_file}")
|
||||
npcs.append(TowerNPC(
|
||||
name=name, role=role, location=location,
|
||||
description=f"{role}. Located in {location}.",
|
||||
source=f"wizards/{wiz_dir.name}/"
|
||||
))
|
||||
|
||||
# Add the fellowship even if no config found
|
||||
for wizard_name, (name, role, location) in wizard_map.items():
|
||||
if not any(n.name == name for n in npcs):
|
||||
npcs.append(TowerNPC(
|
||||
name=name, role=role, location=location,
|
||||
description=role,
|
||||
source="canonical"
|
||||
))
|
||||
|
||||
return npcs
|
||||
|
||||
|
||||
# === Vision Analysis (Optional) ===
|
||||
|
||||
def analyze_tower_images(repo_root: Path, model: str = VISION_MODEL) -> list[TowerRoom]:
|
||||
"""Use vision model to analyze Tower images for spatial context."""
|
||||
rooms = []
|
||||
gallery = repo_root / "grok-imagine-gallery"
|
||||
|
||||
if not gallery.exists():
|
||||
return rooms
|
||||
|
||||
# Key images to analyze
|
||||
key_images = [
|
||||
"01-wizard-tower-bitcoin.jpg",
|
||||
"03-fellowship-of-wizards.jpg",
|
||||
"07-sovereign-sunrise.jpg",
|
||||
"15-father-son-tower.jpg",
|
||||
]
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
import base64
|
||||
|
||||
for img_name in key_images:
|
||||
img_path = gallery / img_name
|
||||
if not img_path.exists():
|
||||
continue
|
||||
|
||||
b64 = base64.b64encode(img_path.read_bytes()).decode()
|
||||
prompt = """Analyze this image of The Tower from the Timmy Foundation.
|
||||
Describe:
|
||||
1. The spatial layout — what rooms/areas can you identify?
|
||||
2. The vertical structure — how many floors or levels?
|
||||
3. Key architectural features — doors, windows, connections
|
||||
4. Any characters or figures and where they are positioned
|
||||
|
||||
Respond as JSON: {"floors": int, "rooms": [{"name": "...", "floor": 0, "description": "..."}], "features": ["..."]}"""
|
||||
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
|
||||
]}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1}
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{OLLAMA_BASE}/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
result = json.loads(resp.read())
|
||||
content = result.get("message", {}).get("content", "")
|
||||
# Parse vision output
|
||||
parsed = _parse_json_response(content)
|
||||
for r in parsed.get("rooms", []):
|
||||
rooms.append(TowerRoom(
|
||||
name=r.get("name", "Unknown"),
|
||||
floor=r.get("floor", 0),
|
||||
description=r.get("description", ""),
|
||||
category="vision",
|
||||
source=f"vision:{img_name}"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f" Vision analysis failed for {img_name}: {e}", file=sys.stderr)
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return rooms
|
||||
|
||||
|
||||
def _parse_json_response(text: str) -> dict:
|
||||
"""Extract JSON from potentially messy response."""
|
||||
cleaned = text.strip()
|
||||
if cleaned.startswith("```"):
|
||||
lines = cleaned.split("\n")[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
cleaned = "\n".join(lines)
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
start = cleaned.find("{")
|
||||
end = cleaned.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
return json.loads(cleaned[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
# === Map Construction ===
|
||||
|
||||
def build_tower_map(repo_root: Path, include_vision: bool = False) -> TowerMap:
|
||||
"""Build the complete holographic map by scanning all sources."""
|
||||
tower = TowerMap()
|
||||
tower.sources_scanned = []
|
||||
|
||||
# 1. Scan gallery
|
||||
gallery_rooms = scan_gallery_index(repo_root)
|
||||
tower.rooms.extend(gallery_rooms)
|
||||
tower.sources_scanned.append("grok-imagine-gallery/INDEX.md")
|
||||
|
||||
# 2. Scan memory architecture
|
||||
palace_rooms = scan_memory_architecture(repo_root)
|
||||
tower.rooms.extend(palace_rooms)
|
||||
tower.sources_scanned.append("docs/MEMORY_ARCHITECTURE.md")
|
||||
|
||||
# 3. Scan design docs
|
||||
design_rooms = scan_design_docs(repo_root)
|
||||
tower.rooms.extend(design_rooms)
|
||||
tower.sources_scanned.append("docs/*.md")
|
||||
|
||||
# 4. Scan wizard configs
|
||||
npcs = scan_wizard_configs(repo_root)
|
||||
tower.npcs.extend(npcs)
|
||||
tower.sources_scanned.append("wizards/*/")
|
||||
|
||||
# 5. Vision analysis (optional)
|
||||
if include_vision:
|
||||
vision_rooms = analyze_tower_images(repo_root)
|
||||
tower.rooms.extend(vision_rooms)
|
||||
tower.sources_scanned.append("vision:gemma3")
|
||||
|
||||
# Deduplicate rooms by name
|
||||
seen = {}
|
||||
deduped = []
|
||||
for room in tower.rooms:
|
||||
if room.name not in seen:
|
||||
seen[room.name] = True
|
||||
deduped.append(room)
|
||||
tower.rooms = deduped
|
||||
|
||||
# Build floors
|
||||
floor_map = {}
|
||||
for room in tower.rooms:
|
||||
if room.floor not in floor_map:
|
||||
floor_map[room.floor] = []
|
||||
floor_map[room.floor].append(room.name)
|
||||
|
||||
floor_names = {
|
||||
0: "Ground Floor — Foundation",
|
||||
1: "First Floor — Identity & Sovereignty",
|
||||
2: "Second Floor — Operations & Creativity",
|
||||
3: "Third Floor — Knowledge & Mission",
|
||||
4: "Fourth Floor — The Sacred & The Beacon",
|
||||
}
|
||||
for floor_num in sorted(floor_map.keys()):
|
||||
tower.floors.append(TowerFloor(
|
||||
number=floor_num,
|
||||
name=floor_names.get(floor_num, f"Floor {floor_num}"),
|
||||
theme=", ".join(set(r.category for r in tower.rooms if r.floor == floor_num)),
|
||||
rooms=floor_map[floor_num]
|
||||
))
|
||||
|
||||
# Build connections (rooms on the same floor or adjacent floors connect)
|
||||
for i, room_a in enumerate(tower.rooms):
|
||||
for room_b in tower.rooms[i + 1:]:
|
||||
if abs(room_a.floor - room_b.floor) <= 1:
|
||||
if room_a.category == room_b.category:
|
||||
tower.connections.append({
|
||||
"from": room_a.name,
|
||||
"to": room_b.name,
|
||||
"type": "corridor" if room_a.floor == room_b.floor else "staircase"
|
||||
})
|
||||
|
||||
# Assign NPCs to rooms
|
||||
for npc in tower.npcs:
|
||||
for room in tower.rooms:
|
||||
if npc.location == room.name:
|
||||
room.occupants.append(npc.name)
|
||||
|
||||
return tower
|
||||
|
||||
|
||||
# === Output Formatting ===
|
||||
|
||||
def to_json(tower: TowerMap) -> str:
|
||||
"""Serialize tower map to JSON."""
|
||||
data = {
|
||||
"name": tower.name,
|
||||
"description": tower.description,
|
||||
"map_version": tower.map_version,
|
||||
"floors": [asdict(f) for f in tower.floors],
|
||||
"rooms": [asdict(r) for r in tower.rooms],
|
||||
"npcs": [asdict(n) for n in tower.npcs],
|
||||
"connections": tower.connections,
|
||||
"sources_scanned": tower.sources_scanned,
|
||||
"stats": {
|
||||
"total_floors": len(tower.floors),
|
||||
"total_rooms": len(tower.rooms),
|
||||
"total_npcs": len(tower.npcs),
|
||||
"total_connections": len(tower.connections),
|
||||
}
|
||||
}
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def to_ascii(tower: TowerMap) -> str:
|
||||
"""Render the tower as an ASCII art map."""
|
||||
lines = []
|
||||
lines.append("=" * 60)
|
||||
lines.append(" THE TOWER — Holographic Architecture Map")
|
||||
lines.append("=" * 60)
|
||||
lines.append("")
|
||||
|
||||
# Render floors top to bottom
|
||||
for floor in sorted(tower.floors, key=lambda f: f.number, reverse=True):
|
||||
lines.append(f" ┌{'─' * 56}┐")
|
||||
lines.append(f" │ FLOOR {floor.number}: {floor.name:<47}│")
|
||||
lines.append(f" ├{'─' * 56}┤")
|
||||
|
||||
# Rooms on this floor
|
||||
floor_rooms = [r for r in tower.rooms if r.floor == floor.number]
|
||||
for room in floor_rooms:
|
||||
# Room box
|
||||
name_display = room.name[:40]
|
||||
lines.append(f" │ ┌{'─' * 50}┐ │")
|
||||
lines.append(f" │ │ {name_display:<49}│ │")
|
||||
|
||||
# NPCs in room
|
||||
if room.occupants:
|
||||
npc_str = ", ".join(room.occupants[:3])
|
||||
lines.append(f" │ │ 👤 {npc_str:<46}│ │")
|
||||
|
||||
# Artifacts
|
||||
if room.artifacts:
|
||||
art_str = room.artifacts[0][:44]
|
||||
lines.append(f" │ │ 📦 {art_str:<46}│ │")
|
||||
|
||||
# Description (truncated)
|
||||
desc = room.description[:46] if room.description else ""
|
||||
if desc:
|
||||
lines.append(f" │ │ {desc:<49}│ │")
|
||||
|
||||
lines.append(f" │ └{'─' * 50}┘ │")
|
||||
|
||||
lines.append(f" └{'─' * 56}┘")
|
||||
lines.append(f" {'│' if floor.number > 0 else ' '}")
|
||||
if floor.number > 0:
|
||||
lines.append(f" ────┼──── staircase")
|
||||
lines.append(f" │")
|
||||
|
||||
# Legend
|
||||
lines.append("")
|
||||
lines.append(" ── LEGEND ──────────────────────────────────────")
|
||||
lines.append(" 👤 NPC/Wizard present 📦 Artifact/Source file")
|
||||
lines.append(" │ Staircase (floor link)")
|
||||
lines.append("")
|
||||
|
||||
# Stats
|
||||
lines.append(f" Floors: {len(tower.floors)} Rooms: {len(tower.rooms)} NPCs: {len(tower.npcs)} Connections: {len(tower.connections)}")
|
||||
lines.append(f" Sources: {', '.join(tower.sources_scanned)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# === CLI ===
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Visual Mapping of Tower Architecture — holographic map builder",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
return {"map": analysis}
|
||||
parser.add_argument("--repo-root", default=".", help="Path to timmy-config repo root")
|
||||
parser.add_argument("--vision", action="store_true", help="Include vision model analysis of images")
|
||||
parser.add_argument("--model", default=VISION_MODEL, help=f"Vision model (default: {VISION_MODEL})")
|
||||
parser.add_argument("--format", choices=["json", "ascii"], default="json", help="Output format")
|
||||
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(map_tower(), indent=2))
|
||||
args = parser.parse_args()
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
|
||||
print(f"Scanning {repo_root}...", file=sys.stderr)
|
||||
tower = build_tower_map(repo_root, include_vision=args.vision)
|
||||
|
||||
if args.format == "json":
|
||||
output = to_json(tower)
|
||||
else:
|
||||
output = to_ascii(tower)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
print(f"Map written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
print(f"\nMapped: {len(tower.floors)} floors, {len(tower.rooms)} rooms, {len(tower.npcs)} NPCs", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
301
tests/test_foundation_accessibility_audit.py
Normal file
301
tests/test_foundation_accessibility_audit.py
Normal file
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for foundation_accessibility_audit.py — verifies WCAG checks."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from foundation_accessibility_audit import (
|
||||
A11yHTMLParser, Severity, A11yViolation,
|
||||
parse_color, contrast_ratio, relative_luminance,
|
||||
run_programmatic_checks, check_page_title, check_images_alt_text,
|
||||
check_heading_hierarchy, check_lang_attribute, check_landmarks,
|
||||
check_skip_nav, check_form_labels, check_link_text,
|
||||
_parse_json_response, format_report, A11yAuditReport, A11yPageResult,
|
||||
)
|
||||
|
||||
|
||||
# === Color Utilities ===
|
||||
|
||||
def test_parse_color_hex6():
|
||||
assert parse_color("#ff0000") == (255, 0, 0)
|
||||
assert parse_color("#000000") == (0, 0, 0)
|
||||
assert parse_color("#ffffff") == (255, 255, 255)
|
||||
print(" PASS: test_parse_color_hex6")
|
||||
|
||||
|
||||
def test_parse_color_hex3():
|
||||
assert parse_color("#f00") == (255, 0, 0)
|
||||
assert parse_color("#abc") == (170, 187, 204)
|
||||
print(" PASS: test_parse_color_hex3")
|
||||
|
||||
|
||||
def test_parse_color_rgb():
|
||||
assert parse_color("rgb(255, 0, 0)") == (255, 0, 0)
|
||||
assert parse_color("rgb( 128 , 64 , 32 )") == (128, 64, 32)
|
||||
print(" PASS: test_parse_color_rgb")
|
||||
|
||||
|
||||
def test_parse_color_named():
|
||||
assert parse_color("white") == (255, 255, 255)
|
||||
assert parse_color("black") == (0, 0, 0)
|
||||
print(" PASS: test_parse_color_named")
|
||||
|
||||
|
||||
def test_parse_color_invalid():
|
||||
assert parse_color("not-a-color") is None
|
||||
assert parse_color("") is None
|
||||
print(" PASS: test_parse_color_invalid")
|
||||
|
||||
|
||||
def test_contrast_ratio_black_white():
|
||||
ratio = contrast_ratio((0, 0, 0), (255, 255, 255))
|
||||
assert ratio > 20 # Should be 21:1
|
||||
print(f" PASS: test_contrast_ratio_black_white ({ratio:.1f}:1)")
|
||||
|
||||
|
||||
def test_contrast_ratio_same():
|
||||
ratio = contrast_ratio((128, 128, 128), (128, 128, 128))
|
||||
assert ratio == 1.0
|
||||
print(" PASS: test_contrast_ratio_same")
|
||||
|
||||
|
||||
def test_contrast_ratio_wcag_aa():
|
||||
# #767676 on white = 4.54:1 (WCAG AA pass for normal text)
|
||||
ratio = contrast_ratio((118, 118, 118), (255, 255, 255))
|
||||
assert ratio >= 4.5
|
||||
print(f" PASS: test_contrast_ratio_wcag_aa ({ratio:.2f}:1)")
|
||||
|
||||
|
||||
# === HTML Parser ===
|
||||
|
||||
def test_parser_title():
|
||||
parser = A11yHTMLParser()
|
||||
parser.feed("<html><head><title>Test Page</title></head></html>")
|
||||
assert parser.title == "Test Page"
|
||||
print(" PASS: test_parser_title")
|
||||
|
||||
|
||||
def test_parser_images():
|
||||
parser = A11yHTMLParser()
|
||||
parser.feed('<html><body><img src="a.png" alt="Alt text"><img src="b.png"></body></html>')
|
||||
assert len(parser.images) == 2
|
||||
assert parser.images[0]["alt"] == "Alt text"
|
||||
assert parser.images[1]["alt"] is None
|
||||
print(" PASS: test_parser_images")
|
||||
|
||||
|
||||
def test_parser_headings():
|
||||
parser = A11yHTMLParser()
|
||||
parser.feed("<html><body><h1>Main</h1><h2>Sub</h2><h4>Skip</h4></body></html>")
|
||||
assert len(parser.headings) == 3
|
||||
assert parser.headings[0] == {"level": 1, "text": "Main"}
|
||||
assert parser.headings[2] == {"level": 4, "text": "Skip"}
|
||||
print(" PASS: test_parser_headings")
|
||||
|
||||
|
||||
def test_parser_lang():
|
||||
parser = A11yHTMLParser()
|
||||
parser.feed('<html lang="en"><body></body></html>')
|
||||
assert parser.lang == "en"
|
||||
print(" PASS: test_parser_lang")
|
||||
|
||||
|
||||
def test_parser_landmarks():
|
||||
parser = A11yHTMLParser()
|
||||
parser.feed("<html><body><nav>Links</nav><main>Content</main></body></html>")
|
||||
tags = {lm["tag"] for lm in parser.landmarks}
|
||||
assert "nav" in tags
|
||||
assert "main" in tags
|
||||
print(" PASS: test_parser_landmarks")
|
||||
|
||||
|
||||
# === Programmatic Checks ===
|
||||
|
||||
def test_check_page_title_empty():
|
||||
parser = A11yHTMLParser()
|
||||
parser.title = ""
|
||||
violations = check_page_title(parser)
|
||||
assert len(violations) == 1
|
||||
assert violations[0].criterion == "2.4.2"
|
||||
assert violations[0].severity == Severity.MAJOR
|
||||
print(" PASS: test_check_page_title_empty")
|
||||
|
||||
|
||||
def test_check_page_title_present():
|
||||
parser = A11yHTMLParser()
|
||||
parser.title = "My Great Page"
|
||||
violations = check_page_title(parser)
|
||||
assert len(violations) == 0
|
||||
print(" PASS: test_check_page_title_present")
|
||||
|
||||
|
||||
def test_check_lang_missing():
|
||||
parser = A11yHTMLParser()
|
||||
parser.lang = ""
|
||||
violations = check_lang_attribute(parser)
|
||||
assert len(violations) == 1
|
||||
assert violations[0].criterion == "3.1.1"
|
||||
print(" PASS: test_check_lang_missing")
|
||||
|
||||
|
||||
def test_check_images_missing_alt():
|
||||
parser = A11yHTMLParser()
|
||||
parser.images = [{"src": "photo.jpg", "alt": None}]
|
||||
violations = check_images_alt_text(parser)
|
||||
assert len(violations) == 1
|
||||
assert violations[0].severity == Severity.CRITICAL
|
||||
print(" PASS: test_check_images_missing_alt")
|
||||
|
||||
|
||||
def test_check_images_with_alt():
|
||||
parser = A11yHTMLParser()
|
||||
parser.images = [{"src": "photo.jpg", "alt": "A photo"}]
|
||||
violations = check_images_alt_text(parser)
|
||||
assert len(violations) == 0
|
||||
print(" PASS: test_check_images_with_alt")
|
||||
|
||||
|
||||
def test_check_images_decorative():
|
||||
parser = A11yHTMLParser()
|
||||
parser.images = [{"src": "deco.png", "alt": "", "role": "presentation"}]
|
||||
violations = check_images_alt_text(parser)
|
||||
assert len(violations) == 0
|
||||
print(" PASS: test_check_images_decorative")
|
||||
|
||||
|
||||
def test_check_headings_no_h1():
|
||||
parser = A11yHTMLParser()
|
||||
parser.headings = [{"level": 2, "text": "Sub"}, {"level": 3, "text": "Sub sub"}]
|
||||
violations = check_heading_hierarchy(parser)
|
||||
assert any(v.criterion == "1.3.1" and "h1" in v.description.lower() for v in violations)
|
||||
print(" PASS: test_check_headings_no_h1")
|
||||
|
||||
|
||||
def test_check_headings_skip():
|
||||
parser = A11yHTMLParser()
|
||||
parser.headings = [{"level": 1, "text": "Main"}, {"level": 4, "text": "Skipped"}]
|
||||
violations = check_heading_hierarchy(parser)
|
||||
assert any("skipped" in v.description.lower() for v in violations)
|
||||
print(" PASS: test_check_headings_skip")
|
||||
|
||||
|
||||
def test_check_skip_nav_missing():
|
||||
parser = A11yHTMLParser()
|
||||
parser.skip_nav = False
|
||||
parser.links = [{"text": "Home", "href": "/"}, {"text": "About", "href": "/about"}]
|
||||
violations = check_skip_nav(parser)
|
||||
assert len(violations) == 1
|
||||
assert violations[0].criterion == "2.4.1"
|
||||
print(" PASS: test_check_skip_nav_missing")
|
||||
|
||||
|
||||
def test_check_link_text_empty():
|
||||
parser = A11yHTMLParser()
|
||||
parser.links = [{"text": "", "href": "/page", "aria_label": ""}]
|
||||
violations = check_link_text(parser)
|
||||
assert len(violations) == 1
|
||||
assert violations[0].criterion == "2.4.4"
|
||||
print(" PASS: test_check_link_text_empty")
|
||||
|
||||
|
||||
def test_check_link_text_generic():
|
||||
parser = A11yHTMLParser()
|
||||
parser.links = [{"text": "Click here", "href": "/page"}]
|
||||
violations = check_link_text(parser)
|
||||
assert any("non-descriptive" in v.description.lower() for v in violations)
|
||||
print(" PASS: test_check_link_text_generic")
|
||||
|
||||
|
||||
def test_run_programmatic_checks_full():
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><title>Good Page</title></head>
|
||||
<body>
|
||||
<nav><a href="#main">Skip to content</a></nav>
|
||||
<main>
|
||||
<h1>Welcome</h1>
|
||||
<h2>Section</h2>
|
||||
<img src="hero.jpg" alt="Hero image">
|
||||
<a href="/about">About Us</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
violations = run_programmatic_checks(html)
|
||||
# This page should have very few or no violations
|
||||
criticals = [v for v in violations if v.severity == Severity.CRITICAL]
|
||||
assert len(criticals) == 0
|
||||
print(f" PASS: test_run_programmatic_checks_full ({len(violations)} minor issues)")
|
||||
|
||||
|
||||
# === JSON Parsing ===
|
||||
|
||||
def test_parse_json_clean():
|
||||
result = _parse_json_response('{"violations": [], "overall_score": 100}')
|
||||
assert result["overall_score"] == 100
|
||||
print(" PASS: test_parse_json_clean")
|
||||
|
||||
|
||||
def test_parse_json_fenced():
|
||||
result = _parse_json_response('```json\n{"overall_score": 80}\n```')
|
||||
assert result["overall_score"] == 80
|
||||
print(" PASS: test_parse_json_fenced")
|
||||
|
||||
|
||||
# === Formatting ===
|
||||
|
||||
def test_format_json():
|
||||
report = A11yAuditReport(site="test.com", pages_audited=1, overall_score=90)
|
||||
output = format_report(report, "json")
|
||||
parsed = json.loads(output)
|
||||
assert parsed["site"] == "test.com"
|
||||
assert parsed["overall_score"] == 90
|
||||
print(" PASS: test_format_json")
|
||||
|
||||
|
||||
def test_format_text():
|
||||
report = A11yAuditReport(site="test.com", pages_audited=1, overall_score=90,
|
||||
summary="Test complete")
|
||||
output = format_report(report, "text")
|
||||
assert "ACCESSIBILITY AUDIT" in output
|
||||
assert "test.com" in output
|
||||
print(" PASS: test_format_text")
|
||||
|
||||
|
||||
# === Run All ===
|
||||
|
||||
def run_all():
|
||||
print("=== foundation_accessibility_audit tests ===")
|
||||
tests = [
|
||||
test_parse_color_hex6, test_parse_color_hex3, test_parse_color_rgb,
|
||||
test_parse_color_named, test_parse_color_invalid,
|
||||
test_contrast_ratio_black_white, test_contrast_ratio_same, test_contrast_ratio_wcag_aa,
|
||||
test_parser_title, test_parser_images, test_parser_headings,
|
||||
test_parser_lang, test_parser_landmarks,
|
||||
test_check_page_title_empty, test_check_page_title_present,
|
||||
test_check_lang_missing,
|
||||
test_check_images_missing_alt, test_check_images_with_alt, test_check_images_decorative,
|
||||
test_check_headings_no_h1, test_check_headings_skip,
|
||||
test_check_skip_nav_missing,
|
||||
test_check_link_text_empty, test_check_link_text_generic,
|
||||
test_run_programmatic_checks_full,
|
||||
test_parse_json_clean, test_parse_json_fenced,
|
||||
test_format_json, test_format_text,
|
||||
]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
print(f" FAIL: {test.__name__} — {e}")
|
||||
failed += 1
|
||||
print(f"\n{'ALL PASSED' if failed == 0 else f'{failed} FAILED'}: {passed}/{len(tests)}")
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if run_all() else 1)
|
||||
215
tests/test_tower_visual_mapper.py
Normal file
215
tests/test_tower_visual_mapper.py
Normal file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for tower_visual_mapper.py — verifies map construction and formatting."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from tower_visual_mapper import (
|
||||
TowerRoom, TowerNPC, TowerFloor, TowerMap,
|
||||
scan_gallery_index, scan_memory_architecture, scan_wizard_configs,
|
||||
build_tower_map, to_json, to_ascii, _gallery_image_to_room,
|
||||
_parse_json_response
|
||||
)
|
||||
|
||||
|
||||
# === Unit Tests ===
|
||||
|
||||
def test_gallery_image_to_room_known():
|
||||
room = _gallery_image_to_room("01-wizard-tower-bitcoin.jpg", "The Tower", "The Origin")
|
||||
assert room is not None
|
||||
assert room.name == "The Tower — Exterior"
|
||||
assert room.floor == 0
|
||||
assert "bitcoin" in room.description.lower() or "sovereign" in room.description.lower()
|
||||
print(" PASS: test_gallery_image_to_room_known")
|
||||
|
||||
|
||||
def test_gallery_image_to_room_unknown():
|
||||
room = _gallery_image_to_room("random-image.jpg", "Something", "The Origin")
|
||||
assert room is None
|
||||
print(" PASS: test_gallery_image_to_room_unknown")
|
||||
|
||||
|
||||
def test_gallery_image_to_room_philosophy():
|
||||
room = _gallery_image_to_room("06-the-paperclip-moment.jpg", "A paperclip", "The Philosophy")
|
||||
assert room is not None
|
||||
assert room.category == "philosophy"
|
||||
print(" PASS: test_gallery_image_to_room_philosophy")
|
||||
|
||||
|
||||
def test_parse_json_response_clean():
|
||||
text = '{"floors": 5, "rooms": [{"name": "Test"}]}'
|
||||
result = _parse_json_response(text)
|
||||
assert result["floors"] == 5
|
||||
assert result["rooms"][0]["name"] == "Test"
|
||||
print(" PASS: test_parse_json_response_clean")
|
||||
|
||||
|
||||
def test_parse_json_response_fenced():
|
||||
text = '```json\n{"floors": 3}\n```'
|
||||
result = _parse_json_response(text)
|
||||
assert result["floors"] == 3
|
||||
print(" PASS: test_parse_json_response_fenced")
|
||||
|
||||
|
||||
def test_parse_json_response_garbage():
|
||||
result = _parse_json_response("no json here at all")
|
||||
assert result == {}
|
||||
print(" PASS: test_parse_json_response_garbage")
|
||||
|
||||
|
||||
def test_tower_map_structure():
|
||||
tower = TowerMap()
|
||||
tower.rooms = [
|
||||
TowerRoom(name="Room A", floor=0, category="test"),
|
||||
TowerRoom(name="Room B", floor=0, category="test"),
|
||||
TowerRoom(name="Room C", floor=1, category="other"),
|
||||
]
|
||||
tower.npcs = [
|
||||
TowerNPC(name="NPC1", role="guard", location="Room A"),
|
||||
]
|
||||
|
||||
output = json.loads(to_json(tower))
|
||||
assert output["name"] == "The Tower"
|
||||
assert output["stats"]["total_rooms"] == 3
|
||||
assert output["stats"]["total_npcs"] == 1
|
||||
print(" PASS: test_tower_map_structure")
|
||||
|
||||
|
||||
def test_to_json():
|
||||
tower = TowerMap()
|
||||
tower.rooms = [TowerRoom(name="Test Room", floor=1)]
|
||||
output = json.loads(to_json(tower))
|
||||
assert output["rooms"][0]["name"] == "Test Room"
|
||||
assert output["rooms"][0]["floor"] == 1
|
||||
print(" PASS: test_to_json")
|
||||
|
||||
|
||||
def test_to_ascii():
|
||||
tower = TowerMap()
|
||||
tower.floors = [TowerFloor(number=0, name="Ground", rooms=["Test Room"])]
|
||||
tower.rooms = [TowerRoom(name="Test Room", floor=0, description="A test")]
|
||||
tower.npcs = []
|
||||
tower.connections = []
|
||||
|
||||
output = to_ascii(tower)
|
||||
assert "THE TOWER" in output
|
||||
assert "Test Room" in output
|
||||
assert "FLOOR 0" in output
|
||||
print(" PASS: test_to_ascii")
|
||||
|
||||
|
||||
def test_to_ascii_with_npcs():
|
||||
tower = TowerMap()
|
||||
tower.floors = [TowerFloor(number=0, name="Ground", rooms=["The Forge"])]
|
||||
tower.rooms = [TowerRoom(name="The Forge", floor=0, occupants=["Bezalel"])]
|
||||
tower.npcs = [TowerNPC(name="Bezalel", role="Builder", location="The Forge")]
|
||||
|
||||
output = to_ascii(tower)
|
||||
assert "Bezalel" in output
|
||||
print(" PASS: test_to_ascii_with_npcs")
|
||||
|
||||
|
||||
def test_scan_gallery_index(tmp_path):
|
||||
# Create mock gallery
|
||||
gallery = tmp_path / "grok-imagine-gallery"
|
||||
gallery.mkdir()
|
||||
index = gallery / "INDEX.md"
|
||||
index.write_text("""# Gallery
|
||||
### The Origin
|
||||
| 01 | wizard-tower-bitcoin.jpg | The Tower, sovereign |
|
||||
| 02 | soul-inscription.jpg | SOUL.md glowing |
|
||||
### The Philosophy
|
||||
| 05 | value-drift-battle.jpg | Blue vs red ships |
|
||||
""")
|
||||
rooms = scan_gallery_index(tmp_path)
|
||||
assert len(rooms) >= 2
|
||||
names = [r.name for r in rooms]
|
||||
assert any("Tower" in n for n in names)
|
||||
assert any("Inscription" in n for n in names)
|
||||
print(" PASS: test_scan_gallery_index")
|
||||
|
||||
|
||||
def test_scan_wizard_configs(tmp_path):
|
||||
wizards = tmp_path / "wizards"
|
||||
for name in ["timmy", "bezalel", "ezra"]:
|
||||
wdir = wizards / name
|
||||
wdir.mkdir(parents=True)
|
||||
(wdir / "config.yaml").write_text("model: test\n")
|
||||
|
||||
npcs = scan_wizard_configs(tmp_path)
|
||||
assert len(npcs) >= 3
|
||||
names = [n.name for n in npcs]
|
||||
assert any("Timmy" in n for n in names)
|
||||
assert any("Bezalel" in n for n in names)
|
||||
print(" PASS: test_scan_wizard_configs")
|
||||
|
||||
|
||||
def test_build_tower_map_empty(tmp_path):
|
||||
tower = build_tower_map(tmp_path, include_vision=False)
|
||||
assert tower.name == "The Tower"
|
||||
# Should still have palace rooms from MEMORY_ARCHITECTURE (won't exist in tmp, but that's fine)
|
||||
assert isinstance(tower.rooms, list)
|
||||
print(" PASS: test_build_tower_map_empty")
|
||||
|
||||
|
||||
def test_room_deduplication():
|
||||
tower = TowerMap()
|
||||
tower.rooms = [
|
||||
TowerRoom(name="Dup Room", floor=0),
|
||||
TowerRoom(name="Dup Room", floor=1), # same name, different floor
|
||||
TowerRoom(name="Unique Room", floor=0),
|
||||
]
|
||||
# Deduplicate in build_tower_map — simulate
|
||||
seen = {}
|
||||
deduped = []
|
||||
for room in tower.rooms:
|
||||
if room.name not in seen:
|
||||
seen[room.name] = True
|
||||
deduped.append(room)
|
||||
assert len(deduped) == 2
|
||||
print(" PASS: test_room_deduplication")
|
||||
|
||||
|
||||
def run_all():
|
||||
print("=== tower_visual_mapper tests ===")
|
||||
tests = [
|
||||
test_gallery_image_to_room_known,
|
||||
test_gallery_image_to_room_unknown,
|
||||
test_gallery_image_to_room_philosophy,
|
||||
test_parse_json_response_clean,
|
||||
test_parse_json_response_fenced,
|
||||
test_parse_json_response_garbage,
|
||||
test_tower_map_structure,
|
||||
test_to_json,
|
||||
test_to_ascii,
|
||||
test_to_ascii_with_npcs,
|
||||
test_scan_gallery_index,
|
||||
test_scan_wizard_configs,
|
||||
test_build_tower_map_empty,
|
||||
test_room_deduplication,
|
||||
]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
if "tmp_path" in test.__code__.co_varnames:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
test(Path(td))
|
||||
else:
|
||||
test()
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
print(f" FAIL: {test.__name__} — {e}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{'ALL PASSED' if failed == 0 else f'{failed} FAILED'}: {passed}/{len(tests)}")
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if run_all() else 1)
|
||||
Reference in New Issue
Block a user