Implement E1 setup-education and capability report (fixtures).
Add owner-safe lesson catalog, fixture-backed capability statuses, CLI, and unit tests without live OAuth or connect scripts.
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
"""Tests for lumina_skills.setup.capability_report domain model and builder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
|
||||
|
||||
from lumina_skills.setup.capability_report import (
|
||||
CapabilityEntry,
|
||||
CapabilityReport,
|
||||
ConnectionStatus,
|
||||
build_capability_report,
|
||||
format_capability_report_text,
|
||||
status_label,
|
||||
)
|
||||
|
||||
|
||||
# ── ConnectionStatus ───────────────────────────────────────────────────────
|
||||
|
||||
def test_connection_status_values():
|
||||
"""All status enum values are correct."""
|
||||
assert ConnectionStatus.CONNECTED.value == "connected"
|
||||
assert ConnectionStatus.SKIPPED.value == "skipped"
|
||||
assert ConnectionStatus.LATER.value == "later"
|
||||
assert ConnectionStatus.ERROR.value == "error"
|
||||
assert ConnectionStatus.OFFLINE.value == "offline"
|
||||
|
||||
|
||||
def test_status_labels():
|
||||
"""Each status has an emoji label."""
|
||||
assert "✅" in status_label(ConnectionStatus.CONNECTED)
|
||||
assert "⏭️" in status_label(ConnectionStatus.SKIPPED)
|
||||
assert "⏳" in status_label(ConnectionStatus.LATER)
|
||||
assert "❌" in status_label(ConnectionStatus.ERROR)
|
||||
assert "📋" in status_label(ConnectionStatus.OFFLINE)
|
||||
|
||||
|
||||
# ── CapabilityEntry ────────────────────────────────────────────────────────
|
||||
|
||||
def test_capability_entry_label():
|
||||
"""Entry label uses status_label."""
|
||||
entry = CapabilityEntry(
|
||||
area="channels",
|
||||
provider="whatsapp",
|
||||
status=ConnectionStatus.CONNECTED,
|
||||
)
|
||||
assert "✅" in entry.label
|
||||
|
||||
|
||||
def test_capability_entry_frozen():
|
||||
"""CapabilityEntry is immutable."""
|
||||
entry = CapabilityEntry(
|
||||
area="channels",
|
||||
provider="whatsapp",
|
||||
status=ConnectionStatus.CONNECTED,
|
||||
)
|
||||
try:
|
||||
entry.status = ConnectionStatus.ERROR
|
||||
assert False, "Should not be able to modify frozen dataclass"
|
||||
except Exception:
|
||||
pass # Expected
|
||||
|
||||
|
||||
# ── CapabilityReport ───────────────────────────────────────────────────────
|
||||
|
||||
def _make_entries(*statuses: ConnectionStatus) -> list[CapabilityEntry]:
|
||||
"""Helper to create CapabilityEntry objects with given statuses."""
|
||||
providers = ["whatsapp", "email", "telegram", "vagaro", "qbo"]
|
||||
areas = ["channels", "channels", "channels", "scheduling", "books"]
|
||||
entries = []
|
||||
for i, status in enumerate(statuses):
|
||||
entries.append(CapabilityEntry(
|
||||
area=areas[i],
|
||||
provider=providers[i],
|
||||
status=status,
|
||||
))
|
||||
return entries
|
||||
|
||||
|
||||
def test_report_connected_count():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.SKIPPED,
|
||||
ConnectionStatus.OFFLINE,
|
||||
ConnectionStatus.ERROR,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
assert report.connected_count() == 2
|
||||
|
||||
|
||||
def test_report_offline_count():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.OFFLINE,
|
||||
ConnectionStatus.OFFLINE,
|
||||
ConnectionStatus.SKIPPED,
|
||||
ConnectionStatus.ERROR,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
assert report.offline_count() == 2
|
||||
|
||||
|
||||
def test_report_skipped_count():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.SKIPPED,
|
||||
ConnectionStatus.LATER,
|
||||
ConnectionStatus.OFFLINE,
|
||||
ConnectionStatus.ERROR,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
assert report.skipped_count() == 2 # SKIPPED + LATER
|
||||
|
||||
|
||||
def test_report_error_count():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.ERROR,
|
||||
ConnectionStatus.ERROR,
|
||||
ConnectionStatus.SKIPPED,
|
||||
ConnectionStatus.OFFLINE,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
assert report.error_count() == 2
|
||||
|
||||
|
||||
def test_report_all_connected():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.CONNECTED,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
assert report.all_connected() is True
|
||||
|
||||
|
||||
def test_report_not_all_connected():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.SKIPPED,
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.CONNECTED,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
assert report.all_connected() is False
|
||||
|
||||
|
||||
def test_report_has_errors():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.ERROR,
|
||||
ConnectionStatus.SKIPPED,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
assert report.has_errors() is True
|
||||
|
||||
|
||||
def test_report_no_errors():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.SKIPPED,
|
||||
ConnectionStatus.OFFLINE,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
assert report.has_errors() is False
|
||||
|
||||
|
||||
def test_report_empty():
|
||||
report = build_capability_report([], "Empty Salon")
|
||||
assert report.connected_count() == 0
|
||||
assert report.offline_count() == 0
|
||||
assert report.skipped_count() == 0
|
||||
assert report.error_count() == 0
|
||||
assert report.all_connected() is False # empty report is not "all connected"
|
||||
assert report.has_errors() is False
|
||||
|
||||
|
||||
# ── to_dict ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_report_to_dict():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.OFFLINE,
|
||||
ConnectionStatus.SKIPPED,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
||||
d = report.to_dict()
|
||||
|
||||
assert d["salon_name"] == "Test Salon"
|
||||
assert d["is_fixture"] is True
|
||||
assert len(d["capabilities"]) == 3
|
||||
assert d["capabilities"][0]["status"] == "connected"
|
||||
assert d["capabilities"][1]["status"] == "offline"
|
||||
assert d["capabilities"][2]["status"] == "skipped"
|
||||
assert d["summary"]["connected"] == 1
|
||||
assert d["summary"]["offline"] == 1
|
||||
assert d["summary"]["skipped_or_later"] == 1
|
||||
assert d["summary"]["errors"] == 0
|
||||
assert d["summary"]["all_connected"] is False
|
||||
|
||||
|
||||
def test_report_to_dict_serializable():
|
||||
"""to_dict output must be JSON-serializable."""
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.OFFLINE,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
d = report.to_dict()
|
||||
# Should not raise.
|
||||
json.dumps(d)
|
||||
|
||||
|
||||
# ── format_capability_report_text ──────────────────────────────────────────
|
||||
|
||||
def test_format_text_includes_salon_name():
|
||||
entries = _make_entries(ConnectionStatus.CONNECTED)
|
||||
report = build_capability_report(entries, "Lumina Hair Studio & Spa")
|
||||
text = format_capability_report_text(report)
|
||||
assert "Lumina Hair Studio & Spa" in text
|
||||
|
||||
|
||||
def test_format_text_fixture_label():
|
||||
entries = _make_entries(ConnectionStatus.OFFLINE)
|
||||
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
||||
text = format_capability_report_text(report)
|
||||
assert "FIXTURE DATA" in text
|
||||
|
||||
|
||||
def test_format_text_no_fixture_label_when_live():
|
||||
entries = _make_entries(ConnectionStatus.CONNECTED)
|
||||
report = build_capability_report(entries, "Test Salon", is_fixture=False)
|
||||
text = format_capability_report_text(report)
|
||||
assert "FIXTURE DATA" not in text
|
||||
|
||||
|
||||
def test_format_text_includes_summary():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.OFFLINE,
|
||||
ConnectionStatus.SKIPPED,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
text = format_capability_report_text(report)
|
||||
assert "Summary" in text
|
||||
assert "Connected:" in text
|
||||
|
||||
|
||||
def test_format_text_error_warning():
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.ERROR,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
text = format_capability_report_text(report)
|
||||
assert "needs attention" in text
|
||||
|
||||
|
||||
def test_format_text_fixture_disclaimer():
|
||||
entries = _make_entries(ConnectionStatus.OFFLINE)
|
||||
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
||||
text = format_capability_report_text(report)
|
||||
assert "fixture" in text.lower() or "demo" in text.lower()
|
||||
|
||||
|
||||
def test_format_text_groups_by_area():
|
||||
entries = [
|
||||
CapabilityEntry("channels", "whatsapp", ConnectionStatus.CONNECTED),
|
||||
CapabilityEntry("channels", "email", ConnectionStatus.SKIPPED),
|
||||
CapabilityEntry("scheduling", "vagaro", ConnectionStatus.OFFLINE),
|
||||
CapabilityEntry("books", "quickbooks_online", ConnectionStatus.OFFLINE),
|
||||
]
|
||||
report = build_capability_report(entries, "Test Salon")
|
||||
text = format_capability_report_text(report)
|
||||
assert "Channels" in text
|
||||
assert "Scheduling" in text
|
||||
assert "Books" in text
|
||||
|
||||
|
||||
def test_format_text_owner_safe():
|
||||
"""Formatted text must never contain forbidden keywords."""
|
||||
forbidden = ["terminal", "docker", "nano", "shell", "bash", "sudo"]
|
||||
entries = _make_entries(
|
||||
ConnectionStatus.CONNECTED,
|
||||
ConnectionStatus.OFFLINE,
|
||||
ConnectionStatus.SKIPPED,
|
||||
ConnectionStatus.ERROR,
|
||||
ConnectionStatus.LATER,
|
||||
)
|
||||
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
||||
text = format_capability_report_text(report).lower()
|
||||
for kw in forbidden:
|
||||
assert kw not in text, f"Formatted text contains forbidden keyword: {kw}"
|
||||
Reference in New Issue
Block a user