From be96b1234f04f694719737a30f50a55112ea5fb2 Mon Sep 17 00:00:00 2001 From: Moritz Graf Date: Sat, 19 Sep 2026 21:04:42 +0200 Subject: [PATCH] feat(skills): add chart-to-chat skill for dbt-charts and quickchart --- AGENTS.md | 6 + skills/chart-to-chat/SKILL.md | 100 +++++ skills/chart-to-chat/scripts/chart.py | 456 +++++++++++++++++++++ skills/chart-to-chat/scripts/test_chart.py | 133 ++++++ 4 files changed, 695 insertions(+) create mode 100644 skills/chart-to-chat/SKILL.md create mode 100755 skills/chart-to-chat/scripts/chart.py create mode 100644 skills/chart-to-chat/scripts/test_chart.py diff --git a/AGENTS.md b/AGENTS.md index b6f45ef..da8fea1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,12 @@ Universal QR code generator featuring the Haumdaucher logo in the center. - **Features:** High-redundancy QR code synthesis (`ERROR_CORRECT_H`), logo overlay with clean white badge, and native Telegram image delivery via `MEDIA:`. - **Tools:** Standalone CLI at `scripts/generate_qr.py`. +### [chart-to-chat](skills/chart-to-chat/SKILL.md) +Instant chart and diagram generation for chat and Telegram. +- **Features:** Transforms structured data (JSON, CSV, key-value metrics) into publication-grade PNG diagrams (bar, line, area, donut, KPI cards, tables) powered by dbt-charts (`dct`) with QuickChart.io fallback. +- **Delivery:** Generates local PNGs and outputs `MEDIA:` for native Telegram image rendering. +- **Tools:** Standalone CLI at `scripts/chart.py`. + ### [grill-me](skills/grill-me/SKILL.md) Relentless, structured interview to stress-test and sharpen plans, architectures, decisions, or ideas before writing code. - **Features:** Decision tree & frontier exploration, Telegram/mobile-optimized batching (1-3 questions per round), facts-first environment research, anti-sycophancy challenge, and final specification/ADR synthesis. diff --git a/skills/chart-to-chat/SKILL.md b/skills/chart-to-chat/SKILL.md new file mode 100644 index 0000000..e9d3705 --- /dev/null +++ b/skills/chart-to-chat/SKILL.md @@ -0,0 +1,100 @@ +--- +name: chart-to-chat +description: Generiert hochwertige Diagramme und Charts (Balken, Linien, Verlauf, Donut/Kuchen, KPI-Karten, Tabellen) aus strukturierten Chat-Daten und sendet sie direkt als Bild (PNG) nach Telegram. Basiert auf dbt-charts (lokales Rendering via dct) mit QuickChart.io-Fallback. +--- + +# 📊 Chart-to-Chat: Diagramm- und Chart-Generator + +Dieser Skill transformiert tabellarische Daten, Verläufe, Statistiken oder Kennzahlen direkt in fertige, publikationsreife **PNG-Grafiken**, die nativ per Telegram-Bildnachricht ausgeliefert werden. + +--- + +## 🎯 Wann diesen Skill verwenden? + +* Immer wenn der Nutzer nach einer Visualisierung fragt: + * *„Zeig mir das als Diagramm / Chart / Balkendiagramm“* + * *„Plotte mir den Verlauf von X über die letzten 7 Tage“* + * *„Visualisiere meine Ausgaben / mein Training / meine Schritte“* + * *„Erstelle eine Grafik / Kurve dazu“* +* Bei Vergleichsdaten oder Verteilungen (z. B. Anteile, Kategorien, Monatsumsätze). +* Für KPI-Zusammenfassungen mit Hauptwert und Delta. + +--- + +## 📈 Welcher Chart-Typ passt wann? + +| Chart-Typ (`--type`) | Bester Anwendungsfall | Beispiel | +|---|---|---| +| `bar` (Default) | Diskrete Kategorien, Rankings, Monatsvergleiche | Schritte pro Tag, Stunden pro Projekt | +| `hbar` | Viele Kategorien mit langen Namen | Ranking der Supermärkte nach Ersparnis | +| `line` | Zeitreihen, kontinuierliche Verläufe, Messwerte | Herzfrequenz, Temperaturverlauf, Gewicht, Watt | +| `area` | Kumulierte Volumina oder Trends über Zeit | Datenverbrauch, kumulierter Regen, Trainingsvolumen | +| `donut` / `pie` | Prozentuale Anteile an einem Ganzen (100 %) | Ausgaben nach Kategorie, Makronährstoff-Verteilung | +| `kpi` | Einzelne prominente Kennzahl mit Kontext/Delta | Aktueller FTP, Monatsumsatz, Gesamtschritte | +| `table` | Strukturierte Datentabelle mit formatierten Spalten | Vergleichstabelle von Produkten oder Tarifen | + +--- + +## 🛠️ Ausführung + +Führe das Skript `chart.py` via `uv run` aus (Cluster-Pfad: `/home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py`): + +### 1. Daten direkt als JSON-String übergeben + +```bash +uv run /home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py \ + --title "Wöchentliche Laufdistanz" \ + --type bar \ + --data '[{"tag": "Mo", "km": 8.5}, {"tag": "Mi", "km": 12.0}, {"tag": "Fr", "km": 6.2}, {"tag": "So", "km": 16.5}]' +``` + +### 2. Daten per Pipe (Standard-Eingabe) übergeben + +Besonders empfohlen bei größeren Datenmengen oder um Shell-Escaping-Probleme zu vermeiden: + +```bash +cat << 'EOF' | uv run /home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py --title "Ruhepuls Trend" --type line --data - +[ + {"datum": "12.09.", "puls": 54}, + {"datum": "13.09.", "puls": 52}, + {"datum": "14.09.", "puls": 55}, + {"datum": "15.09.", "puls": 53}, + {"datum": "16.09.", "puls": 51} +] +EOF +``` + +### 3. Donut-Chart für Anteile / Verteilungen + +```bash +uv run /home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py \ + --title "Ausgaben September" \ + --type donut \ + --data '[{"kategorie": "Miete", "euro": 950}, {"kategorie": "Lebensmittel", "euro": 420}, {"kategorie": "Sport", "euro": 110}]' +``` + +### 4. Optionen des CLI-Tools + +- `--title ""`: Überschrift des Diagramms. +- `--notes ""`: Optionale Unterzeile / Erläuterung. +- `--type bar|hbar|line|area|donut|kpi|table`: Visualisierungstyp. +- `--data ""` oder `--data -` (Stdin): Eingabedaten. +- `--x ` / `--y `: Manuelle Angabe der Achsenspalten (wird ansonsten automatisch erkannt). +- `--engine auto|dct|quickchart`: `auto` nutzt primär das lokale `dbt-charts` und fällt bei Bedarf transparent auf `QuickChart.io` zurück. + +--- + +## 📱 Auslieferung an Telegram + +Das Skript gibt als letzte Zeile den Pfad der erzeugten PNG-Datei im Format `MEDIA:` aus, z. B.: +```text +MEDIA:/tmp/charts/chart_a1b2c3d4.png +``` + +Schließe diese Zeile einfach in deine finale Antwort an den Nutzer ein. OpenClaw versendet das Diagramm dadurch automatisch als Bild im Chat: + +```markdown +Hier ist die Übersicht deiner Laufdistanzen für diese Woche: + +MEDIA:/tmp/charts/chart_a1b2c3d4.png +``` diff --git a/skills/chart-to-chat/scripts/chart.py b/skills/chart-to-chat/scripts/chart.py new file mode 100755 index 0000000..1a435ae --- /dev/null +++ b/skills/chart-to-chat/scripts/chart.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +""" +chart.py — Universal Chart & Graph Generator for OpenClaw / Telegram + +Generates clean, publication-grade chart images from JSON/CSV data using: +1. Primary: dbt-charts (`dct render`) — local, offline, Vega-Lite/resvg based. +2. Fallback: QuickChart.io — fast HTTP API (Chart.js), zero local dependencies. + +Outputs `MEDIA:` on stdout for native Telegram image delivery. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Union + + +KNOWN_DCT_PATHS = [ + Path("/home/node/.openclaw/.local/bin/dct"), + Path("/home/openclaw/.local/bin/dct"), + Path("/home/node/.openclaw/.local/share/uv/tools/dbt-charts/bin/dct"), + Path("/home/openclaw/.local/share/uv/tools/dbt-charts/bin/dct"), + Path.home() / ".local/bin/dct", +] + + +def find_dct_executable() -> Optional[str]: + """Locate the dct executable in PATH or well-known install locations.""" + # 1. System PATH + found = shutil.which("dct") + if found: + return found + + # 2. Well-known absolute container/local paths + for candidate in KNOWN_DCT_PATHS: + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + + # 3. Check if uv tool run dct works + uv_path = shutil.which("uv") or "/home/node/.openclaw/bin/uv" + if os.path.exists(uv_path) and os.access(uv_path, os.X_OK): + try: + res = subprocess.run( + [uv_path, "tool", "run", "dct", "--version"], + capture_output=True, + text=True, + timeout=5, + ) + if res.returncode == 0: + return "uv_tool_run" + except Exception: + pass + + return None + + +def parse_input_data(raw_data: str) -> Tuple[List[str], List[List[Any]], Optional[str], Optional[str]]: + """ + Parse input string (JSON or CSV) into (columns, rows, suggested_x, suggested_y). + Supports: + - List of dicts: [{"label": "A", "val": 10}, {"label": "B", "val": 20}] + - Dict of values: {"A": 10, "B": 20} + - Explicit table: {"columns": ["k", "v"], "values": [["A", 10], ["B", 20]]} + - Single KPI: {"value": 1284, "label": "Revenue", "delta": "+12%"} + """ + raw_data = raw_data.strip() + if not raw_data: + raise ValueError("Input data is empty.") + + # Try JSON + parsed = json.loads(raw_data) + + # Case 1: Dict with explicit columns & values + if isinstance(parsed, dict) and "columns" in parsed and ("values" in parsed or "rows" in parsed): + cols = [str(c) for c in parsed["columns"]] + rows = parsed.get("values") or parsed.get("rows") or [] + x_col = cols[0] if len(cols) > 0 else None + y_col = cols[1] if len(cols) > 1 else None + return cols, rows, x_col, y_col + + # Case 2: Dict of key-value pairs (e.g. {"Mo": 10, "Di": 15}) + if isinstance(parsed, dict) and not any(k in parsed for k in ["columns", "values", "rows", "type", "chart"]): + cols = ["category", "value"] + rows = [[k, v] for k, v in parsed.items()] + return cols, rows, "category", "value" + + # Case 3: List of dicts (most common agent format) + if isinstance(parsed, list): + if not parsed: + raise ValueError("Data list is empty.") + if isinstance(parsed[0], dict): + # Collect unique keys maintaining insertion order + cols: List[str] = [] + for item in parsed: + for k in item.keys(): + if k not in cols: + cols.append(k) + + rows: List[List[Any]] = [] + for item in parsed: + rows.append([item.get(c) for c in cols]) + + # Infer x (category/date) and y (measure) + x_col = None + y_col = None + for c in cols: + low = c.lower() + if low in ["x", "label", "name", "date", "datum", "month", "monat", "day", "tag", "category", "kategorie", "typ", "woche", "week"]: + x_col = c + break + if not x_col and len(cols) > 0: + x_col = cols[0] + + for c in cols: + if c == x_col: + continue + low = c.lower() + if low in ["y", "value", "wert", "count", "anzahl", "amount", "menge", "total", "summe", "preis", "price", "revenue", "umsatz", "km", "kmh", "watt", "hr", "puls"]: + y_col = c + break + if not y_col and len(cols) > 1: + y_col = cols[1] + + return cols, rows, x_col, y_col + + elif isinstance(parsed[0], list): + # List of lists without explicit headers + cols = [f"col_{i+1}" for i in range(len(parsed[0]))] + return cols, parsed, cols[0], cols[1] if len(cols) > 1 else None + + raise ValueError(f"Unrecognized data structure: {type(parsed)}") + + +def render_dbt_chart( + dct_cmd: str, + title: str, + chart_type: str, + columns: List[str], + rows: List[List[Any]], + x_col: str, + y_col: str, + output_path: Path, + notes: Optional[str] = None, + theme: Optional[str] = None, +) -> bool: + """Render a chart using dbt-charts (dct render).""" + scratch_dir = Path(tempfile.mkdtemp(prefix="dct_render_")) + try: + # 1. dbt_charts.yml project file (mandatory for dct) + (scratch_dir / "dbt_charts.yml").write_text("# dbt charts project\nversion: 1\n", encoding="utf-8") + + # Normalize chart type + ct = chart_type.lower() + if ct in ["hbar", "horizontal_bar"]: + ct = "bar" + is_horizontal = True + else: + is_horizontal = False + + if ct in ["donut", "pie"]: + dbt_type = "donut" + elif ct in ["line", "trend"]: + dbt_type = "line" + elif ct in ["area"]: + dbt_type = "area" + elif ct in ["kpi"]: + dbt_type = "kpi" + elif ct in ["table"]: + dbt_type = "table" + else: + dbt_type = "bar" + + # Build board YAML + board: Dict[str, Any] = { + "title": title or "Dashboard", + "queries": { + "dataset": { + "columns": columns, + "values": rows, + } + }, + "charts": {}, + "rows": ["main_chart"], + } + + if notes: + board["notes"] = notes + + chart_spec: Dict[str, Any] = { + "type": dbt_type, + "query": "dataset", + } + if title: + chart_spec["title"] = title + + if dbt_type in ["donut", "pie"]: + chart_spec["theta"] = y_col + chart_spec["color"] = x_col + elif dbt_type == "kpi": + chart_spec["value"] = y_col or columns[0] + chart_spec["label"] = x_col or "Value" + elif dbt_type == "table": + # Table automatically uses query columns + pass + else: + # Bar / Line / Area + if is_horizontal: + chart_spec["x"] = y_col + chart_spec["y"] = x_col + else: + chart_spec["x"] = x_col + chart_spec["y"] = y_col + + board["charts"]["main_chart"] = chart_spec + + import yaml # PyYAML is standard in python / uv environments + board_file = scratch_dir / "board.yml" + board_file.write_text(yaml.safe_dump(board, sort_keys=False), encoding="utf-8") + + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Build command + if dct_cmd == "uv_tool_run": + cmd = ["uv", "tool", "run", "dct", "render", str(board_file), "--format", "png", "--output", str(output_path)] + else: + cmd = [dct_cmd, "render", str(board_file), "--format", "png", "--output", str(output_path)] + + proc = subprocess.run( + cmd, + cwd=str(scratch_dir), + capture_output=True, + text=True, + timeout=30, + ) + + if proc.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0: + return True + else: + sys.stderr.write(f"[dct render warning] exit={proc.returncode} stderr={proc.stderr.strip()}\n") + return False + + except Exception as e: + sys.stderr.write(f"[dct render error] {e}\n") + return False + finally: + shutil.rmtree(scratch_dir, ignore_errors=True) + + +def render_quickchart( + title: str, + chart_type: str, + columns: List[str], + rows: List[List[Any]], + x_col: str, + y_col: str, + output_path: Path, + width: int = 800, + height: int = 450, +) -> bool: + """Render chart via QuickChart.io API as fallback.""" + try: + x_idx = columns.index(x_col) + y_idx = columns.index(y_col) + + labels = [str(r[x_idx]) for r in rows] + values = [] + for r in rows: + v = r[y_idx] + try: + values.append(float(v) if v is not None else 0) + except (ValueError, TypeError): + values.append(0) + + ct = chart_type.lower() + if ct in ["donut", "pie"]: + qc_type = "doughnut" if ct == "donut" else "pie" + bg_colors = [ + "#3366cc", "#dc3912", "#ff9900", "#109618", "#990099", + "#3b3eac", "#0099c6", "#dd4477", "#66aa00", "#b82e2e", + ] + dataset = { + "data": values, + "backgroundColor": bg_colors[:len(values)], + } + elif ct in ["line", "trend"]: + qc_type = "line" + dataset = { + "label": y_col, + "data": values, + "borderColor": "#3e64ff", + "backgroundColor": "rgba(62, 100, 255, 0.1)", + "fill": False, + "tension": 0.2, + } + elif ct in ["area"]: + qc_type = "line" + dataset = { + "label": y_col, + "data": values, + "borderColor": "#3e64ff", + "backgroundColor": "rgba(62, 100, 255, 0.25)", + "fill": True, + "tension": 0.2, + } + else: + qc_type = "bar" + dataset = { + "label": y_col, + "data": values, + "backgroundColor": "#3e64ff", + } + + chart_config = { + "type": qc_type, + "data": { + "labels": labels, + "datasets": [dataset], + }, + "options": { + "title": { + "display": bool(title), + "text": title, + "fontSize": 18, + }, + "legend": { + "display": qc_type in ["pie", "doughnut"], + "position": "top", + }, + "plugins": { + "datalabels": { + "display": False, + } + } + } + } + + query_params = { + "w": str(width), + "h": str(height), + "devicePixelRatio": "2.0", + "c": json.dumps(chart_config), + } + url = "https://quickchart.io/chart?" + urllib.parse.urlencode(query_params) + + req = urllib.request.Request(url, headers={"User-Agent": "OpenClaw-Chart/1.0"}) + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status == 200: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(resp.read()) + return True + + except Exception as e: + sys.stderr.write(f"[quickchart error] {e}\n") + + return False + + +def main() -> None: + parser = argparse.ArgumentParser(description="OpenClaw Universal Chart Generator") + parser.add_argument("--data", help="JSON data string, or '-' for stdin") + parser.add_argument("--file", help="Path to JSON or CSV file") + parser.add_argument("--type", default="bar", choices=["bar", "hbar", "line", "area", "donut", "pie", "kpi", "table"], help="Chart type") + parser.add_argument("--title", default="Übersicht", help="Chart or Dashboard title") + parser.add_argument("--notes", help="Subtitle or explanatory notes") + parser.add_argument("--x", help="X-axis / category column name") + parser.add_argument("--y", help="Y-axis / measure column name") + parser.add_argument("--output", help="Target PNG path (default: /tmp/charts/chart_.png)") + parser.add_argument("--engine", default="auto", choices=["auto", "dct", "quickchart"], help="Rendering engine") + parser.add_argument("--width", type=int, default=800, help="Width in pixels (QuickChart)") + parser.add_argument("--height", type=int, default=450, help="Height in pixels (QuickChart)") + + args = parser.parse_args() + + # Read data + raw_data = None + if args.data and args.data != "-": + raw_data = args.data + elif args.file: + raw_data = Path(args.file).read_text(encoding="utf-8") + elif args.data == "-" or not sys.stdin.isatty(): + raw_data = sys.stdin.read() + + if not raw_data: + parser.error("No input data provided. Use --data, --file, or pipe data via STDIN.") + + try: + columns, rows, inferred_x, inferred_y = parse_input_data(raw_data) + except Exception as e: + sys.stderr.write(f"Error parsing input data: {e}\n") + sys.exit(1) + + x_col = args.x or inferred_x or (columns[0] if columns else "x") + y_col = args.y or inferred_y or (columns[1] if len(columns) > 1 else columns[0]) + + # Destination path + if args.output: + output_path = Path(args.output) + else: + content_hash = hashlib.md5(f"{args.title}_{args.type}_{raw_data}".encode("utf-8")).hexdigest()[:8] + output_path = Path(f"/tmp/charts/chart_{content_hash}.png") + + rendered = False + + # Attempt 1: dbt Charts (unless engine is explicitly quickchart) + if args.engine in ["auto", "dct"]: + dct_bin = find_dct_executable() + if dct_bin: + rendered = render_dbt_chart( + dct_cmd=dct_bin, + title=args.title, + chart_type=args.type, + columns=columns, + rows=rows, + x_col=x_col, + y_col=y_col, + output_path=output_path, + notes=args.notes, + ) + elif args.engine == "dct": + sys.stderr.write("Error: dct executable not found and --engine dct was requested.\n") + sys.exit(1) + + # Attempt 2: QuickChart Fallback + if not rendered and args.engine in ["auto", "quickchart"]: + rendered = render_quickchart( + title=args.title, + chart_type=args.type, + columns=columns, + rows=rows, + x_col=x_col, + y_col=y_col, + output_path=output_path, + width=args.width, + height=args.height, + ) + + if not rendered: + sys.stderr.write("Error: Chart rendering failed on all available engines.\n") + sys.exit(1) + + # Deliver MEDIA line for Telegram/OpenClaw + print(f"MEDIA:{output_path.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/skills/chart-to-chat/scripts/test_chart.py b/skills/chart-to-chat/scripts/test_chart.py new file mode 100644 index 0000000..19979b4 --- /dev/null +++ b/skills/chart-to-chat/scripts/test_chart.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Unit and integration tests for chart.py +""" + +import json +import os +import subprocess +import sys +import unittest +from pathlib import Path + +# Add scripts directory to path +SCRIPTS_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPTS_DIR)) + +from chart import parse_input_data + + +class TestChartParser(unittest.TestCase): + def test_list_of_dicts(self): + raw = json.dumps([ + {"day": "Mo", "temp": 18}, + {"day": "Di", "temp": 22}, + {"day": "Mi", "temp": 19}, + ]) + cols, rows, x, y = parse_input_data(raw) + self.assertEqual(cols, ["day", "temp"]) + self.assertEqual(len(rows), 3) + self.assertEqual(x, "day") + self.assertEqual(y, "temp") + + def test_dict_key_value(self): + raw = json.dumps({"Bier": 4.5, "Pizza": 12.0, "Kaffee": 3.2}) + cols, rows, x, y = parse_input_data(raw) + self.assertEqual(cols, ["category", "value"]) + self.assertEqual(len(rows), 3) + self.assertEqual(x, "category") + self.assertEqual(y, "value") + + def test_explicit_columns_values(self): + raw = json.dumps({ + "columns": ["Monat", "Umsatz"], + "values": [["Jan", 100], ["Feb", 150]] + }) + cols, rows, x, y = parse_input_data(raw) + self.assertEqual(cols, ["Monat", "Umsatz"]) + self.assertEqual(len(rows), 2) + self.assertEqual(x, "Monat") + self.assertEqual(y, "Umsatz") + + +class TestChartCLI(unittest.TestCase): + def run_cli(self, args, stdin_input=None): + cmd = [sys.executable, str(SCRIPTS_DIR / "chart.py")] + args + res = subprocess.run( + cmd, + input=stdin_input, + capture_output=True, + text=True, + timeout=30, + ) + return res + + def test_bar_chart_generation(self): + data = json.dumps([ + {"label": "Projekt A", "stunden": 14}, + {"label": "Projekt B", "stunden": 28}, + {"label": "Projekt C", "stunden": 8}, + ]) + out_path = Path("/tmp/charts/test_bar.png") + if out_path.exists(): + out_path.unlink() + + res = self.run_cli(["--type", "bar", "--title", "Projektstunden", "--data", data, "--output", str(out_path)]) + self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}") + self.assertTrue(res.stdout.strip().startswith("MEDIA:")) + self.assertTrue(out_path.exists()) + self.assertGreater(out_path.stat().st_size, 1000) + + def test_line_chart_stdin(self): + data = json.dumps([ + {"datum": "2026-09-10", "puls": 62}, + {"datum": "2026-09-11", "puls": 58}, + {"datum": "2026-09-12", "puls": 65}, + {"datum": "2026-09-13", "puls": 59}, + ]) + out_path = Path("/tmp/charts/test_line.png") + if out_path.exists(): + out_path.unlink() + + res = self.run_cli(["--type", "line", "--title", "Ruhepuls Verlauf", "--data", "-", "--output", str(out_path)], stdin_input=data) + self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}") + self.assertTrue(res.stdout.strip().startswith("MEDIA:")) + self.assertTrue(out_path.exists()) + self.assertGreater(out_path.stat().st_size, 1000) + + def test_donut_chart(self): + data = json.dumps([ + {"bereich": "Wohnen", "kosten": 950}, + {"bereich": "Essen", "kosten": 450}, + {"bereich": "Mobilität", "kosten": 120}, + {"bereich": "Freizeit", "kosten": 200}, + ]) + out_path = Path("/tmp/charts/test_donut.png") + if out_path.exists(): + out_path.unlink() + + res = self.run_cli(["--type", "donut", "--title", "Ausgaben", "--data", data, "--output", str(out_path)]) + self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}") + self.assertTrue(res.stdout.strip().startswith("MEDIA:")) + self.assertTrue(out_path.exists()) + self.assertGreater(out_path.stat().st_size, 1000) + + def test_quickchart_fallback_engine(self): + data = json.dumps([ + {"fach": "Mathe", "note": 1.7}, + {"fach": "Physik", "note": 2.0}, + {"fach": "Informatik", "note": 1.0}, + ]) + out_path = Path("/tmp/charts/test_quickchart.png") + if out_path.exists(): + out_path.unlink() + + res = self.run_cli(["--engine", "quickchart", "--type", "bar", "--title", "Notenspiegel", "--data", data, "--output", str(out_path)]) + self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}") + self.assertTrue(res.stdout.strip().startswith("MEDIA:")) + self.assertTrue(out_path.exists()) + self.assertGreater(out_path.stat().st_size, 1000) + + +if __name__ == "__main__": + unittest.main()