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,62 @@
|
||||
{
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"is_fixture": true,
|
||||
"generated_at": "2026-07-27T00:00:00Z",
|
||||
"note": "Fixture data for demo capability report — not live connection state",
|
||||
"capabilities": [
|
||||
{
|
||||
"area": "identity",
|
||||
"provider": "assistant_name",
|
||||
"status": "connected",
|
||||
"details": "Assistant named 'Lumina'"
|
||||
},
|
||||
{
|
||||
"area": "profile",
|
||||
"provider": "owner_profile",
|
||||
"status": "connected",
|
||||
"details": "Business name, timezone, hours, and hard rules configured"
|
||||
},
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "whatsapp",
|
||||
"status": "connected",
|
||||
"details": "WhatsApp channel active"
|
||||
},
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "email",
|
||||
"status": "skipped",
|
||||
"details": "Owner chose to skip email channel for now"
|
||||
},
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "telegram",
|
||||
"status": "later",
|
||||
"details": "Planned for future setup"
|
||||
},
|
||||
{
|
||||
"area": "scheduling",
|
||||
"provider": "vagaro",
|
||||
"status": "offline",
|
||||
"details": "Not yet connected — using fixture appointment data"
|
||||
},
|
||||
{
|
||||
"area": "scheduling",
|
||||
"provider": "square",
|
||||
"status": "skipped",
|
||||
"details": "Owner uses Vagaro, not Square"
|
||||
},
|
||||
{
|
||||
"area": "books",
|
||||
"provider": "quickbooks_online",
|
||||
"status": "offline",
|
||||
"details": "Not yet connected — using fixture financial data"
|
||||
},
|
||||
{
|
||||
"area": "expectations",
|
||||
"provider": "boundaries",
|
||||
"status": "connected",
|
||||
"details": "Owner reviewed draft-only, no-pay, no-publish boundaries"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"is_fixture": true,
|
||||
"generated_at": "2026-07-27T00:00:00Z",
|
||||
"note": "Fixture data — all capabilities connected (demo scenario)",
|
||||
"capabilities": [
|
||||
{
|
||||
"area": "identity",
|
||||
"provider": "assistant_name",
|
||||
"status": "connected",
|
||||
"details": "Assistant named 'Lumina'"
|
||||
},
|
||||
{
|
||||
"area": "profile",
|
||||
"provider": "owner_profile",
|
||||
"status": "connected",
|
||||
"details": "Full profile configured"
|
||||
},
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "whatsapp",
|
||||
"status": "connected",
|
||||
"details": "WhatsApp channel active"
|
||||
},
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "email",
|
||||
"status": "connected",
|
||||
"details": "Email channel active"
|
||||
},
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "telegram",
|
||||
"status": "connected",
|
||||
"details": "Telegram channel active"
|
||||
},
|
||||
{
|
||||
"area": "scheduling",
|
||||
"provider": "vagaro",
|
||||
"status": "connected",
|
||||
"details": "Vagaro API connected"
|
||||
},
|
||||
{
|
||||
"area": "books",
|
||||
"provider": "quickbooks_online",
|
||||
"status": "connected",
|
||||
"details": "QuickBooks Online read-only connected"
|
||||
},
|
||||
{
|
||||
"area": "expectations",
|
||||
"provider": "boundaries",
|
||||
"status": "connected",
|
||||
"details": "Boundaries reviewed and confirmed"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"is_fixture": true,
|
||||
"generated_at": "2026-07-27T00:00:00Z",
|
||||
"note": "Fixture data — includes error state for testing",
|
||||
"capabilities": [
|
||||
{
|
||||
"area": "identity",
|
||||
"provider": "assistant_name",
|
||||
"status": "connected",
|
||||
"details": "Assistant named 'Lumina'"
|
||||
},
|
||||
{
|
||||
"area": "profile",
|
||||
"provider": "owner_profile",
|
||||
"status": "connected",
|
||||
"details": "Profile configured"
|
||||
},
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "whatsapp",
|
||||
"status": "error",
|
||||
"details": "WhatsApp webhook not responding — operator needs to check"
|
||||
},
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "email",
|
||||
"status": "skipped",
|
||||
"details": "Skipped"
|
||||
},
|
||||
{
|
||||
"area": "scheduling",
|
||||
"provider": "vagaro",
|
||||
"status": "offline",
|
||||
"details": "Not yet connected"
|
||||
},
|
||||
{
|
||||
"area": "books",
|
||||
"provider": "quickbooks_online",
|
||||
"status": "error",
|
||||
"details": "QBO OAuth token expired — operator needs to refresh"
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Owner Setup UX (post-install)
|
||||
|
||||
**Status:** Outline. Scenarios: [design/scenarios.md](../design/scenarios.md).
|
||||
**Status:** Skill implemented (fixtures only). See [skills/setup-education/](../skills/setup-education/). Scenarios: [design/scenarios.md](../design/scenarios.md).
|
||||
|
||||
## Prerequisite
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ Shared deterministic library used by Salon_Assistant skills. All code here is
|
||||
| `domain.py` | ✅ | Domain types: `Appointment`, `Gap`, `DayBoard`, `AppointmentStatus` |
|
||||
| `board_builder.py` | ✅ | Deterministic board builder: gaps, confirmation flags, formatting |
|
||||
| `providers/scheduling/fixture_provider.py` | ✅ | Fixture JSON loader for scheduling data |
|
||||
| `setup/capability_report.py` | ✅ | Capability report domain model + builder (E6) |
|
||||
| `setup/lesson_catalog.py` | ✅ | Static setup education lessons (E1) |
|
||||
| `providers/setup/fixture_provider.py` | ✅ | Fixture JSON loader for capability state |
|
||||
| `providers/scheduling/` | ⏳ | Vagaro / Square adapters (future) |
|
||||
| `providers/books/` | ⏳ | QuickBooks Online adapters (future) |
|
||||
| `providers/mcp/` | ⏳ | MCP client helpers / allowlist metadata (future) |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Setup education providers — fixture loaders for capability state."""
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Fixture provider for setup/capability data.
|
||||
|
||||
Loads capability state fixtures from JSON files under data/fixtures/setup/.
|
||||
All output is labeled `is_fixture: True` so the owner never sees
|
||||
silent fake live data.
|
||||
|
||||
This is the *only* data source for the capability report until live
|
||||
connection state is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
from typing import Any
|
||||
|
||||
from lumina_skills.setup.capability_report import (
|
||||
CapabilityEntry,
|
||||
CapabilityReport,
|
||||
ConnectionStatus,
|
||||
build_capability_report,
|
||||
)
|
||||
|
||||
# Mapping from fixture status strings to domain enum.
|
||||
_STATUS_MAP: dict[str, ConnectionStatus] = {
|
||||
"connected": ConnectionStatus.CONNECTED,
|
||||
"skipped": ConnectionStatus.SKIPPED,
|
||||
"later": ConnectionStatus.LATER,
|
||||
"error": ConnectionStatus.ERROR,
|
||||
"offline": ConnectionStatus.OFFLINE,
|
||||
}
|
||||
|
||||
|
||||
def _parse_status(raw: str) -> ConnectionStatus:
|
||||
"""Convert a fixture status string to ConnectionStatus.
|
||||
|
||||
Raises:
|
||||
ValueError: If the status string is not recognized.
|
||||
"""
|
||||
key = raw.lower().strip()
|
||||
if key not in _STATUS_MAP:
|
||||
raise ValueError(
|
||||
f"Unknown capability status {raw!r}. "
|
||||
f"Expected one of: {', '.join(sorted(_STATUS_MAP))}"
|
||||
)
|
||||
return _STATUS_MAP[key]
|
||||
|
||||
|
||||
def load_capability_fixture(fixture_path: str | pathlib.Path) -> CapabilityReport:
|
||||
"""Load a capability report from a fixture JSON file.
|
||||
|
||||
Expected top-level shape:
|
||||
```json
|
||||
{
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"is_fixture": true,
|
||||
"capabilities": [
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "whatsapp",
|
||||
"status": "connected",
|
||||
"details": "WhatsApp channel active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Args:
|
||||
fixture_path: Path to a JSON fixture file.
|
||||
|
||||
Returns:
|
||||
A fully populated CapabilityReport.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the fixture file does not exist.
|
||||
ValueError: If the fixture JSON is malformed or has unknown status.
|
||||
"""
|
||||
path = pathlib.Path(fixture_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Fixture not found: {path}")
|
||||
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
salon_name = raw.get("salon_name", "Unknown Salon")
|
||||
is_fixture = raw.get("is_fixture", True)
|
||||
|
||||
capabilities: list[CapabilityEntry] = []
|
||||
for i, cap_raw in enumerate(raw.get("capabilities", [])):
|
||||
# Validate required fields with clear error messages.
|
||||
for required_field in ("area", "provider", "status"):
|
||||
if required_field not in cap_raw:
|
||||
raise ValueError(
|
||||
f"Capability entry {i} missing required field {required_field!r}. "
|
||||
f"Each entry must have 'area', 'provider', and 'status'."
|
||||
)
|
||||
capabilities.append(CapabilityEntry(
|
||||
area=cap_raw["area"],
|
||||
provider=cap_raw["provider"],
|
||||
status=_parse_status(cap_raw["status"]),
|
||||
details=cap_raw.get("details", ""),
|
||||
))
|
||||
|
||||
return build_capability_report(
|
||||
capabilities=capabilities,
|
||||
salon_name=salon_name,
|
||||
is_fixture=is_fixture,
|
||||
)
|
||||
|
||||
|
||||
def load_fixture_metadata(fixture_path: str | pathlib.Path) -> dict[str, Any]:
|
||||
"""Load non-capability metadata from a fixture file.
|
||||
|
||||
Returns salon_name, is_fixture, generated_at, note, etc.
|
||||
"""
|
||||
path = pathlib.Path(fixture_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Fixture not found: {path}")
|
||||
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"salon_name": raw.get("salon_name", "Unknown Salon"),
|
||||
"is_fixture": raw.get("is_fixture", True),
|
||||
"generated_at": raw.get("generated_at", ""),
|
||||
"note": raw.get("note", ""),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Setup education — deterministic modules for E1 setup-education and E6 capability report."""
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Deterministic capability report domain model and builder.
|
||||
|
||||
Covers use cases E6 (Capability report) and E7 (Degraded mode).
|
||||
All logic is deterministic — no model inference, no network calls.
|
||||
See design/det-vs-inf.md for the deterministic boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Status enum ────────────────────────────────────────────────────────────
|
||||
|
||||
class ConnectionStatus(str, Enum):
|
||||
"""Plain-language connection status for a capability area.
|
||||
|
||||
These map directly to what the owner sees in the capability report.
|
||||
"""
|
||||
CONNECTED = "connected"
|
||||
SKIPPED = "skipped"
|
||||
LATER = "later"
|
||||
ERROR = "error"
|
||||
OFFLINE = "offline" # fixtures/demo mode — never silent as live
|
||||
|
||||
|
||||
# ── Plain-language labels ──────────────────────────────────────────────────
|
||||
|
||||
_STATUS_LABELS: dict[ConnectionStatus, str] = {
|
||||
ConnectionStatus.CONNECTED: "✅ Connected",
|
||||
ConnectionStatus.SKIPPED: "⏭️ Skipped",
|
||||
ConnectionStatus.LATER: "⏳ Set up later",
|
||||
ConnectionStatus.ERROR: "❌ Error — needs attention",
|
||||
ConnectionStatus.OFFLINE: "📋 Offline / fixtures",
|
||||
}
|
||||
|
||||
|
||||
def status_label(status: ConnectionStatus) -> str:
|
||||
"""Return the owner-facing emoji label for a connection status."""
|
||||
return _STATUS_LABELS[status]
|
||||
|
||||
|
||||
# ── Domain types ───────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityEntry:
|
||||
"""A single capability area in the report (e.g., scheduling, books, channels).
|
||||
|
||||
Attributes:
|
||||
area: Category — "scheduling", "books", "channels", "profile", "identity".
|
||||
provider: Specific provider name — "vagaro", "square", "qbo", "whatsapp", etc.
|
||||
status: Current connection status.
|
||||
details: Optional additional context for the owner.
|
||||
"""
|
||||
area: str
|
||||
provider: str
|
||||
status: ConnectionStatus
|
||||
details: str = ""
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""Owner-facing status label with emoji."""
|
||||
return status_label(self.status)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityReport:
|
||||
"""The complete capability report for a salon.
|
||||
|
||||
This is the structured output that the setup-education skill presents.
|
||||
All data is deterministic — no model inference.
|
||||
|
||||
Attributes:
|
||||
salon_name: Display name of the salon.
|
||||
is_fixture: True when data comes from fixtures (demo mode).
|
||||
capabilities: List of capability entries.
|
||||
"""
|
||||
salon_name: str
|
||||
is_fixture: bool
|
||||
capabilities: list[CapabilityEntry] = field(default_factory=list)
|
||||
|
||||
# ── Aggregation helpers ──────────────────────────────────────────────
|
||||
|
||||
def connected_count(self) -> int:
|
||||
"""Number of capabilities with CONNECTED status."""
|
||||
return sum(1 for c in self.capabilities if c.status == ConnectionStatus.CONNECTED)
|
||||
|
||||
def offline_count(self) -> int:
|
||||
"""Number of capabilities with OFFLINE status."""
|
||||
return sum(1 for c in self.capabilities if c.status == ConnectionStatus.OFFLINE)
|
||||
|
||||
def skipped_count(self) -> int:
|
||||
"""Number of capabilities with SKIPPED or LATER status."""
|
||||
return sum(
|
||||
1 for c in self.capabilities
|
||||
if c.status in (ConnectionStatus.SKIPPED, ConnectionStatus.LATER)
|
||||
)
|
||||
|
||||
def error_count(self) -> int:
|
||||
"""Number of capabilities with ERROR status."""
|
||||
return sum(1 for c in self.capabilities if c.status == ConnectionStatus.ERROR)
|
||||
|
||||
def all_connected(self) -> bool:
|
||||
"""True if there is at least one capability and every capability is CONNECTED.
|
||||
|
||||
Returns False for an empty report — a salon with zero capabilities
|
||||
is not "all connected."
|
||||
"""
|
||||
if not self.capabilities:
|
||||
return False
|
||||
return all(c.status == ConnectionStatus.CONNECTED for c in self.capabilities)
|
||||
|
||||
def has_errors(self) -> bool:
|
||||
"""True if any capability has ERROR status."""
|
||||
return any(c.status == ConnectionStatus.ERROR for c in self.capabilities)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize to a plain dict for JSON output."""
|
||||
return {
|
||||
"salon_name": self.salon_name,
|
||||
"is_fixture": self.is_fixture,
|
||||
"capabilities": [
|
||||
{
|
||||
"area": c.area,
|
||||
"provider": c.provider,
|
||||
"status": c.status.value,
|
||||
"label": c.label,
|
||||
"details": c.details,
|
||||
}
|
||||
for c in self.capabilities
|
||||
],
|
||||
"summary": {
|
||||
"connected": self.connected_count(),
|
||||
"offline": self.offline_count(),
|
||||
"skipped_or_later": self.skipped_count(),
|
||||
"errors": self.error_count(),
|
||||
"all_connected": self.all_connected(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Builder ────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_capability_report(
|
||||
capabilities: list[CapabilityEntry],
|
||||
salon_name: str,
|
||||
is_fixture: bool = False,
|
||||
) -> CapabilityReport:
|
||||
"""Build a CapabilityReport from a list of CapabilityEntry objects.
|
||||
|
||||
Args:
|
||||
capabilities: List of capability entries (from fixtures or live state).
|
||||
salon_name: Display name of the salon.
|
||||
is_fixture: True when data comes from fixtures (demo mode).
|
||||
|
||||
Returns:
|
||||
A fully populated CapabilityReport.
|
||||
"""
|
||||
return CapabilityReport(
|
||||
salon_name=salon_name,
|
||||
is_fixture=is_fixture,
|
||||
capabilities=list(capabilities),
|
||||
)
|
||||
|
||||
|
||||
def format_capability_report_text(report: CapabilityReport) -> str:
|
||||
"""Format a CapabilityReport as structured text for chat display.
|
||||
|
||||
This is deterministic formatting — no model inference.
|
||||
The model may rephrase when presenting to the owner, but the
|
||||
facts come from this function.
|
||||
|
||||
Output is owner-safe: no terminal, docker, nano, or shell instructions.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# Header.
|
||||
fixture_tag = " [📋 FIXTURE DATA]" if report.is_fixture else ""
|
||||
lines.append(f"═══ Capability Report — {report.salon_name}{fixture_tag} ═══")
|
||||
lines.append("")
|
||||
|
||||
# Group by area.
|
||||
areas: dict[str, list[CapabilityEntry]] = {}
|
||||
for cap in report.capabilities:
|
||||
areas.setdefault(cap.area, []).append(cap)
|
||||
|
||||
# Define display order.
|
||||
area_order = ["identity", "profile", "channels", "scheduling", "books"]
|
||||
ordered_areas = [a for a in area_order if a in areas]
|
||||
# Append any areas not in the predefined order.
|
||||
for a in areas:
|
||||
if a not in ordered_areas:
|
||||
ordered_areas.append(a)
|
||||
|
||||
for area in ordered_areas:
|
||||
entries = areas[area]
|
||||
area_display = area.replace("_", " ").title()
|
||||
lines.append(f"── {area_display} ──")
|
||||
for entry in entries:
|
||||
provider_display = entry.provider.replace("_", " ").title()
|
||||
lines.append(f" {entry.label} {provider_display}")
|
||||
if entry.details:
|
||||
lines.append(f" {entry.details}")
|
||||
lines.append("")
|
||||
|
||||
# Summary.
|
||||
lines.append("── Summary ──")
|
||||
lines.append(f" Connected: {report.connected_count()} | "
|
||||
f"Offline/fixtures: {report.offline_count()} | "
|
||||
f"Skipped/later: {report.skipped_count()} | "
|
||||
f"Errors: {report.error_count()}")
|
||||
|
||||
if report.has_errors():
|
||||
lines.append("")
|
||||
lines.append("⚠️ Some connections need attention. Ask your operator to check the error details.")
|
||||
|
||||
if report.is_fixture:
|
||||
lines.append("")
|
||||
lines.append("📋 This report uses fixture (demo) data. Real statuses appear after connections are live.")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Static lesson catalog for setup education (E1).
|
||||
|
||||
Each lesson corresponds to a step in docs/SETUP_UX.md.
|
||||
All text is owner-safe: browser/vendor UI steps only.
|
||||
No terminal, docker, nano, or shell instructions.
|
||||
|
||||
This is deterministic data — no model inference.
|
||||
See design/det-vs-inf.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Forbidden keywords ─────────────────────────────────────────────────────
|
||||
|
||||
# Owner-safe text must NEVER contain these keywords.
|
||||
#
|
||||
# Single-word keywords are matched with word boundaries (\b...\b).
|
||||
# Multi-word phrases are matched as literal phrases with word boundaries.
|
||||
# This prevents false positives like "brew installation" matching "brew install".
|
||||
FORBIDDEN_KEYWORDS = frozenset([
|
||||
"terminal",
|
||||
"docker",
|
||||
"nano",
|
||||
"vim",
|
||||
"emacs",
|
||||
"shell",
|
||||
"bash",
|
||||
"sudo",
|
||||
"apt-get",
|
||||
"brew install",
|
||||
"pip install",
|
||||
"npm install",
|
||||
"curl",
|
||||
"wget",
|
||||
"chmod",
|
||||
"ssh",
|
||||
"rsync",
|
||||
"scp",
|
||||
"docker-compose",
|
||||
"docker run",
|
||||
"docker exec",
|
||||
"kubectl",
|
||||
"make install",
|
||||
"git clone",
|
||||
"git push",
|
||||
"git pull",
|
||||
])
|
||||
|
||||
|
||||
def _keyword_matches(text_lower: str, keyword: str) -> bool:
|
||||
"""Check if a keyword matches in text using word-boundary-aware matching.
|
||||
|
||||
Single-word keywords use \\b boundaries. Multi-word phrases use
|
||||
\\b at the start and end of the full phrase to avoid partial matches
|
||||
like "brew installation" matching "brew install".
|
||||
|
||||
Args:
|
||||
text_lower: Lowercased text to search.
|
||||
keyword: The forbidden keyword or phrase to search for.
|
||||
|
||||
Returns:
|
||||
True if the keyword was found with proper word boundaries.
|
||||
"""
|
||||
escaped = re.escape(keyword)
|
||||
pattern = r'\b' + escaped + r'\b'
|
||||
return bool(re.search(pattern, text_lower))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Lesson:
|
||||
"""A single setup education lesson.
|
||||
|
||||
Attributes:
|
||||
step: Step number (1–7, matching SETUP_UX.md).
|
||||
title: Short title for the lesson.
|
||||
description: What this step accomplishes.
|
||||
instructions: Owner-safe instructions (browser/vendor UI only).
|
||||
what_it_enables: What capabilities become available after this step.
|
||||
possible_outcomes: List of possible end states.
|
||||
area: Capability area this lesson belongs to.
|
||||
"""
|
||||
step: int
|
||||
title: str
|
||||
description: str
|
||||
instructions: str
|
||||
what_it_enables: str
|
||||
possible_outcomes: list[str] = field(default_factory=list)
|
||||
area: str = ""
|
||||
|
||||
|
||||
# ── Lesson catalog ─────────────────────────────────────────────────────────
|
||||
|
||||
LESSON_CATALOG: list[Lesson] = [
|
||||
Lesson(
|
||||
step=1,
|
||||
title="Name the assistant",
|
||||
description="Choose a name for your salon assistant. This becomes how the assistant identifies itself in conversations.",
|
||||
instructions=(
|
||||
"Tell the assistant what you'd like to call it — for example 'Lumina', "
|
||||
"'Salon Helper', or any name you prefer. The assistant will use this name "
|
||||
"in all conversations going forward."
|
||||
),
|
||||
what_it_enables="Personalized assistant identity across all channels.",
|
||||
possible_outcomes=["connected", "skipped"],
|
||||
area="identity",
|
||||
),
|
||||
Lesson(
|
||||
step=2,
|
||||
title="Profile intake",
|
||||
description="Share your business details so the assistant can tailor its help to your salon.",
|
||||
instructions=(
|
||||
"Answer a few questions about your salon: business name, timezone, "
|
||||
"business hours, staff names, and any hard rules you want the assistant "
|
||||
"to follow (e.g., 'never send messages without my approval')."
|
||||
),
|
||||
what_it_enables="Context-aware responses, correct timezone handling, staff-aware boards.",
|
||||
possible_outcomes=["connected", "skipped", "later"],
|
||||
area="profile",
|
||||
),
|
||||
Lesson(
|
||||
step=3,
|
||||
title="Connect channels",
|
||||
description="Choose which messaging channels you want to use with the assistant.",
|
||||
instructions=(
|
||||
"Decide which channels to use:\n"
|
||||
" • WhatsApp — chat with the assistant on your phone\n"
|
||||
" • Email — thread-based conversations\n"
|
||||
" • Telegram — bot-style chat\n\n"
|
||||
"Your operator will configure the channels you choose. You can skip any "
|
||||
"channel now and add it later."
|
||||
),
|
||||
what_it_enables="Talk to the assistant on your preferred messaging apps.",
|
||||
possible_outcomes=["connected", "skipped", "later", "error"],
|
||||
area="channels",
|
||||
),
|
||||
Lesson(
|
||||
step=4,
|
||||
title="Connect scheduling",
|
||||
description="Link your scheduling system (Vagaro and/or Square) so the assistant can read your appointments.",
|
||||
instructions=(
|
||||
"If you use Vagaro:\n"
|
||||
" 1. Log in to your Vagaro account in your browser.\n"
|
||||
" 2. Go to Settings → Integrations and generate an API key.\n"
|
||||
" 3. Share the API key with your operator.\n\n"
|
||||
"If you use Square:\n"
|
||||
" 1. Log in to the Square Developer Portal in your browser.\n"
|
||||
" 2. Create an application and generate an access token.\n"
|
||||
" 3. Share the token with your operator.\n\n"
|
||||
"The assistant reads your schedule — it never modifies bookings or charges clients."
|
||||
),
|
||||
what_it_enables="Daily board, appointment gaps, confirmation flags, client prep cards.",
|
||||
possible_outcomes=["connected", "skipped", "later", "error"],
|
||||
area="scheduling",
|
||||
),
|
||||
Lesson(
|
||||
step=5,
|
||||
title="Connect books",
|
||||
description="Link QuickBooks Online so the assistant can show you a read-only financial picture.",
|
||||
instructions=(
|
||||
"1. Log in to QuickBooks Online in your browser.\n"
|
||||
"2. Authorize the assistant's read-only access when prompted.\n"
|
||||
"3. Your operator will complete the connection on their end.\n\n"
|
||||
"The assistant reads your books — it never pays bills, creates charges, "
|
||||
"or modifies financial records."
|
||||
),
|
||||
what_it_enables="Books snapshot, open invoices, bills due, vendor spend lookup.",
|
||||
possible_outcomes=["connected", "skipped", "later", "error"],
|
||||
area="books",
|
||||
),
|
||||
Lesson(
|
||||
step=6,
|
||||
title="Set expectations",
|
||||
description="Understand what the assistant can and cannot do.",
|
||||
instructions=(
|
||||
"The assistant is designed with these boundaries:\n"
|
||||
" • Drafts messages — you send them (no silent auto-send)\n"
|
||||
" • Drafts social posts — you publish them (no auto-publish)\n"
|
||||
" • Reads your books — never pays bills or charges cards\n"
|
||||
" • Reads your schedule — never modifies bookings\n"
|
||||
" • Labels demo data clearly — never shows fake data as real\n\n"
|
||||
"These boundaries are built in and cannot be turned off."
|
||||
),
|
||||
what_it_enables="Clear understanding of assistant capabilities and safety boundaries.",
|
||||
possible_outcomes=["connected"],
|
||||
area="expectations",
|
||||
),
|
||||
Lesson(
|
||||
step=7,
|
||||
title="Review capability report",
|
||||
description="See a summary of what is connected, what is offline, and what was skipped.",
|
||||
instructions=(
|
||||
"Ask the assistant for a capability report. It will show:\n"
|
||||
" • ✅ Connected — working integrations\n"
|
||||
" • 📋 Offline/fixtures — demo data (not live)\n"
|
||||
" • ⏭️ Skipped — you chose to skip this step\n"
|
||||
" • ⏳ Set up later — planned for future\n"
|
||||
" • ❌ Error — needs operator attention\n\n"
|
||||
"This report updates as you connect more services."
|
||||
),
|
||||
what_it_enables="Clear picture of what works and what needs attention.",
|
||||
possible_outcomes=["connected"],
|
||||
area="report",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_lesson(step: int) -> Optional[Lesson]:
|
||||
"""Get a lesson by step number (1–7).
|
||||
|
||||
Returns None if the step number is not found.
|
||||
"""
|
||||
for lesson in LESSON_CATALOG:
|
||||
if lesson.step == step:
|
||||
return lesson
|
||||
return None
|
||||
|
||||
|
||||
def get_lessons_by_area(area: str) -> list[Lesson]:
|
||||
"""Get all lessons for a given capability area."""
|
||||
return [l for l in LESSON_CATALOG if l.area == area]
|
||||
|
||||
|
||||
def get_all_lessons() -> list[Lesson]:
|
||||
"""Return the full lesson catalog in step order."""
|
||||
return list(LESSON_CATALOG)
|
||||
|
||||
|
||||
def format_lesson_text(lesson: Lesson) -> str:
|
||||
"""Format a single lesson as structured text for chat display.
|
||||
|
||||
This is deterministic formatting — no model inference.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append(f"Step {lesson.step}: {lesson.title}")
|
||||
lines.append("")
|
||||
lines.append(lesson.description)
|
||||
lines.append("")
|
||||
lines.append("What to do:")
|
||||
lines.append(lesson.instructions)
|
||||
lines.append("")
|
||||
lines.append(f"This enables: {lesson.what_it_enables}")
|
||||
lines.append("")
|
||||
lines.append(f"Possible outcomes: {', '.join(lesson.possible_outcomes)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_all_lessons_text() -> str:
|
||||
"""Format the full lesson catalog as structured text."""
|
||||
lines: list[str] = []
|
||||
lines.append("═══ Setup Education — All Steps ═══")
|
||||
lines.append("")
|
||||
for lesson in LESSON_CATALOG:
|
||||
lines.append(format_lesson_text(lesson))
|
||||
lines.append("")
|
||||
lines.append("─" * 50)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def is_owner_safe(text: str) -> bool:
|
||||
"""Check that text does not contain forbidden keywords.
|
||||
|
||||
Owner-safe text must never contain terminal, docker, nano, or shell
|
||||
instructions. This is a deterministic check using word-boundary-aware
|
||||
matching to avoid false positives on multi-word phrases.
|
||||
|
||||
Args:
|
||||
text: Text to validate.
|
||||
|
||||
Returns:
|
||||
True if the text is owner-safe (no forbidden keywords found).
|
||||
"""
|
||||
text_lower = text.lower()
|
||||
found = [kw for kw in FORBIDDEN_KEYWORDS if _keyword_matches(text_lower, kw)]
|
||||
return len(found) == 0
|
||||
|
||||
|
||||
def validate_lesson_owner_safe(lesson: Lesson) -> list[str]:
|
||||
"""Validate that a lesson contains no forbidden keywords.
|
||||
|
||||
Checks title, description, instructions, what_it_enables, and
|
||||
possible_outcomes.
|
||||
|
||||
Returns a list of forbidden keywords found (empty if clean).
|
||||
"""
|
||||
all_text = (
|
||||
f"{lesson.title} {lesson.description} {lesson.instructions} "
|
||||
f"{lesson.what_it_enables} {' '.join(lesson.possible_outcomes)}"
|
||||
)
|
||||
text_lower = all_text.lower()
|
||||
return [kw for kw in FORBIDDEN_KEYWORDS if _keyword_matches(text_lower, kw)]
|
||||
|
||||
|
||||
def validate_catalog_owner_safe() -> dict[int, list[str]]:
|
||||
"""Validate the entire lesson catalog for owner-safe text.
|
||||
|
||||
Returns a dict mapping step numbers to lists of forbidden keywords found.
|
||||
Empty dict means all lessons are clean.
|
||||
"""
|
||||
violations: dict[int, list[str]] = {}
|
||||
for lesson in LESSON_CATALOG:
|
||||
found = validate_lesson_owner_safe(lesson)
|
||||
if found:
|
||||
violations[lesson.step] = found
|
||||
return violations
|
||||
@@ -1,5 +1,46 @@
|
||||
# `setup-education` (scaffold)
|
||||
# `setup-education`
|
||||
|
||||
**Status:** Not implemented. Implementation requires explicit **build**.
|
||||
**Status:** Implemented (fixtures only).
|
||||
|
||||
Intent: see [design/use-cases.md](../../design/use-cases.md) and [design/scenarios.md](../../design/scenarios.md).
|
||||
Owner-safe connect education and capability report for use cases E1, E6, and E7.
|
||||
|
||||
## What it does
|
||||
|
||||
- **7 setup lessons** — step-by-step education for connecting the assistant
|
||||
(name, profile, channels, scheduling, books, expectations, capability report)
|
||||
- **Capability report** — plain-language summary of what is connected, offline,
|
||||
skipped, or in error
|
||||
- **Owner-safe** — all education text validated to never contain terminal,
|
||||
docker, nano, or shell instructions
|
||||
- **Fixture-labeled** — all demo data clearly marked `📋 FIXTURE DATA`
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Capability report (default demo data)
|
||||
python skills/setup-education/scripts/build_capability_report.py
|
||||
|
||||
# JSON output
|
||||
python skills/setup-education/scripts/build_capability_report.py --format json
|
||||
|
||||
# Show lesson 4 (connect scheduling)
|
||||
python skills/setup-education/scripts/build_capability_report.py --lesson 4
|
||||
|
||||
# Show all lessons
|
||||
python skills/setup-education/scripts/build_capability_report.py --all-lessons
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `data/fixtures/setup/capability_matrix.json` | Demo: mixed states (connected, offline, skipped, later) |
|
||||
| `data/fixtures/setup/capability_matrix_all_connected.json` | Demo: all connected |
|
||||
| `data/fixtures/setup/capability_matrix_with_errors.json` | Demo: includes error states |
|
||||
|
||||
## Design references
|
||||
|
||||
- Use case: [E1 — Educational setup](../../design/use-cases.md)
|
||||
- Use case: [E6 — Capability report](../../design/use-cases.md)
|
||||
- Use case: [E7 — Degraded mode](../../design/use-cases.md)
|
||||
- Setup UX: [docs/SETUP_UX.md](../../docs/SETUP_UX.md)
|
||||
|
||||
@@ -10,9 +10,126 @@ Owner-safe connect education and capability report.
|
||||
|
||||
## Description
|
||||
|
||||
Guides the owner through connecting their SaaS integrations (Square, QBO, Vagaro) and messaging channels. Provides a capability report showing what is connected.
|
||||
Guides the owner through connecting their SaaS integrations (Square, QBO, Vagaro)
|
||||
and messaging channels. Provides a capability report showing what is connected,
|
||||
what is offline/fixtures, and what was skipped.
|
||||
|
||||
All education text is **owner-safe**: browser/vendor UI steps only. Never
|
||||
terminal, docker, nano, or shell instructions.
|
||||
|
||||
## What it does
|
||||
|
||||
- Presents 7 setup education lessons (matching `docs/SETUP_UX.md` steps 1–7)
|
||||
- Builds a capability report from fixture or live connection state
|
||||
- Labels all fixture data as `📋 FIXTURE DATA` — never silent fake live data
|
||||
- Validates that education text never contains forbidden keywords
|
||||
- Outputs structured text or JSON
|
||||
|
||||
## Data sources
|
||||
|
||||
| Source | Status | Label in output |
|
||||
|--------|--------|-----------------|
|
||||
| Fixtures (JSON) | ✅ Implemented | `📋 FIXTURE DATA` |
|
||||
| Live connection state | Not yet | `LIVE DATA` (future) |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Owner-safe: no terminal instructions.
|
||||
- Browser/vendor UI steps only.
|
||||
- Deterministic facts from fixtures; no model inference for capability data.
|
||||
- Owner-safe: no terminal/docker/nano/shell instructions in education text.
|
||||
- Fixtures/stubs only — no real OAuth, no live secrets.
|
||||
- Fixture `is_fixture` flag always `true` — never silent as live.
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Build capability report from fixtures (default demo data)
|
||||
python skills/setup-education/scripts/build_capability_report.py
|
||||
|
||||
# JSON output
|
||||
python skills/setup-education/scripts/build_capability_report.py --format json
|
||||
|
||||
# Show a specific lesson
|
||||
python skills/setup-education/scripts/build_capability_report.py --lesson 4
|
||||
|
||||
# Show all lessons
|
||||
python skills/setup-education/scripts/build_capability_report.py --all-lessons
|
||||
|
||||
# Use a different fixture
|
||||
python skills/setup-education/scripts/build_capability_report.py \
|
||||
--fixtures data/fixtures/setup/capability_matrix_all_connected.json
|
||||
```
|
||||
|
||||
### Programmatic
|
||||
|
||||
```python
|
||||
from lumina_skills.providers.setup.fixture_provider import load_capability_fixture
|
||||
from lumina_skills.setup.capability_report import format_capability_report_text
|
||||
from lumina_skills.setup.lesson_catalog import get_lesson, format_lesson_text
|
||||
|
||||
# Capability report
|
||||
report = load_capability_fixture("data/fixtures/setup/capability_matrix.json")
|
||||
print(format_capability_report_text(report))
|
||||
|
||||
# Individual lesson
|
||||
lesson = get_lesson(4)
|
||||
print(format_lesson_text(lesson))
|
||||
```
|
||||
|
||||
## Output format
|
||||
|
||||
### Text (default)
|
||||
|
||||
Structured text grouped by area (identity, profile, channels, scheduling, books)
|
||||
with emoji status labels and a summary line.
|
||||
|
||||
### JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"is_fixture": true,
|
||||
"capabilities": [
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "whatsapp",
|
||||
"status": "connected",
|
||||
"label": "✅ Connected",
|
||||
"details": "WhatsApp channel active"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"connected": 4,
|
||||
"offline": 2,
|
||||
"skipped_or_later": 2,
|
||||
"errors": 0,
|
||||
"all_connected": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Skill spec and usage |
|
||||
| `scripts/build_capability_report.py` | CLI entrypoint |
|
||||
| `../../skills/_lib/lumina_skills/setup/capability_report.py` | Domain model + builder |
|
||||
| `../../skills/_lib/lumina_skills/setup/lesson_catalog.py` | Static lesson steps |
|
||||
| `../../skills/_lib/lumina_skills/providers/setup/fixture_provider.py` | Fixture loader |
|
||||
| `../../data/fixtures/setup/` | Fixture JSON files |
|
||||
|
||||
## Design references
|
||||
|
||||
- Use case: [E1 — Educational setup](../../design/use-cases.md)
|
||||
- Use case: [E6 — Capability report](../../design/use-cases.md)
|
||||
- Use case: [E7 — Degraded mode](../../design/use-cases.md)
|
||||
- Setup UX: [docs/SETUP_UX.md](../../docs/SETUP_UX.md)
|
||||
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
|
||||
|
||||
## Future
|
||||
|
||||
- Live connection state provider (replaces fixtures)
|
||||
- Per-lesson progress tracking
|
||||
- Automated lesson sequencing
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a capability report from setup fixtures.
|
||||
|
||||
Usage:
|
||||
python build_capability_report.py [--fixtures PATH] [--salon NAME] [--format text|json]
|
||||
|
||||
All data is labeled as fixture/offline — never silent fake live data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
# Ensure the _lib package is importable regardless of cwd.
|
||||
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(_REPO_ROOT / "skills" / "_lib"))
|
||||
|
||||
from lumina_skills.providers.setup.fixture_provider import (
|
||||
load_capability_fixture,
|
||||
load_fixture_metadata,
|
||||
)
|
||||
from lumina_skills.setup.capability_report import format_capability_report_text
|
||||
from lumina_skills.setup.lesson_catalog import (
|
||||
format_all_lessons_text,
|
||||
format_lesson_text,
|
||||
get_lesson,
|
||||
get_all_lessons,
|
||||
)
|
||||
|
||||
# Default fixture: Claire Bennett demo capability matrix.
|
||||
_DEFAULT_FIXTURE = _REPO_ROOT / "data" / "fixtures" / "setup" / "capability_matrix.json"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build a capability report from setup fixture data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fixtures",
|
||||
type=pathlib.Path,
|
||||
default=_DEFAULT_FIXTURE,
|
||||
help="Path to capability fixture JSON file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--salon",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Override salon name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format (default: text).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lesson",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Show a specific setup lesson (1-7) instead of the capability report.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all-lessons",
|
||||
action="store_true",
|
||||
help="Show all setup lessons instead of the capability report.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Lesson mode.
|
||||
if args.lesson is not None:
|
||||
lesson = get_lesson(args.lesson)
|
||||
if lesson is None:
|
||||
print(f"Error: No lesson found for step {args.lesson}", file=sys.stderr)
|
||||
return 1
|
||||
print(format_lesson_text(lesson))
|
||||
return 0
|
||||
|
||||
if args.all_lessons:
|
||||
print(format_all_lessons_text())
|
||||
return 0
|
||||
|
||||
# Capability report mode.
|
||||
try:
|
||||
report = load_capability_fixture(args.fixtures)
|
||||
except FileNotFoundError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
||||
print(f"Error parsing fixture: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Override salon name if requested.
|
||||
if args.salon:
|
||||
from lumina_skills.setup.capability_report import build_capability_report
|
||||
report = build_capability_report(
|
||||
capabilities=report.capabilities,
|
||||
salon_name=args.salon,
|
||||
is_fixture=report.is_fixture,
|
||||
)
|
||||
|
||||
# Output.
|
||||
if args.format == "json":
|
||||
print(json.dumps(report.to_dict(), indent=2))
|
||||
else:
|
||||
print(format_capability_report_text(report))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Tests for lumina_skills.setup.lesson_catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
|
||||
|
||||
from lumina_skills.setup.lesson_catalog import (
|
||||
FORBIDDEN_KEYWORDS,
|
||||
Lesson,
|
||||
LESSON_CATALOG,
|
||||
format_lesson_text,
|
||||
format_all_lessons_text,
|
||||
get_lesson,
|
||||
get_lessons_by_area,
|
||||
get_all_lessons,
|
||||
is_owner_safe,
|
||||
validate_lesson_owner_safe,
|
||||
validate_catalog_owner_safe,
|
||||
)
|
||||
|
||||
|
||||
# ── Lesson catalog structure ──────────────────────────────────────────────
|
||||
|
||||
def test_catalog_has_seven_lessons():
|
||||
"""Catalog must have exactly 7 lessons (matching SETUP_UX steps 1-7)."""
|
||||
assert len(LESSON_CATALOG) == 7
|
||||
|
||||
|
||||
def test_lesson_steps_are_sequential():
|
||||
"""Lesson steps must be 1 through 7."""
|
||||
steps = [l.step for l in LESSON_CATALOG]
|
||||
assert steps == list(range(1, 8))
|
||||
|
||||
|
||||
def test_all_lessons_returns_all():
|
||||
assert len(get_all_lessons()) == 7
|
||||
|
||||
|
||||
def test_get_lesson_by_step():
|
||||
lesson = get_lesson(1)
|
||||
assert lesson is not None
|
||||
assert lesson.step == 1
|
||||
assert "name" in lesson.title.lower() or "assistant" in lesson.title.lower()
|
||||
|
||||
|
||||
def test_get_lesson_out_of_range():
|
||||
assert get_lesson(0) is None
|
||||
assert get_lesson(8) is None
|
||||
assert get_lesson(-1) is None
|
||||
|
||||
|
||||
def test_get_lessons_by_area():
|
||||
channel_lessons = get_lessons_by_area("channels")
|
||||
assert len(channel_lessons) >= 1
|
||||
assert all(l.area == "channels" for l in channel_lessons)
|
||||
|
||||
|
||||
def test_get_lessons_by_area_empty():
|
||||
assert get_lessons_by_area("nonexistent_area") == []
|
||||
|
||||
|
||||
# ── Lesson content ────────────────────────────────────────────────────────
|
||||
|
||||
def test_lesson_1_identity():
|
||||
lesson = get_lesson(1)
|
||||
assert lesson is not None
|
||||
assert lesson.area == "identity"
|
||||
|
||||
|
||||
def test_lesson_2_profile():
|
||||
lesson = get_lesson(2)
|
||||
assert lesson is not None
|
||||
assert lesson.area == "profile"
|
||||
|
||||
|
||||
def test_lesson_3_channels():
|
||||
lesson = get_lesson(3)
|
||||
assert lesson is not None
|
||||
assert lesson.area == "channels"
|
||||
|
||||
|
||||
def test_lesson_4_scheduling():
|
||||
lesson = get_lesson(4)
|
||||
assert lesson is not None
|
||||
assert lesson.area == "scheduling"
|
||||
|
||||
|
||||
def test_lesson_5_books():
|
||||
lesson = get_lesson(5)
|
||||
assert lesson is not None
|
||||
assert lesson.area == "books"
|
||||
|
||||
|
||||
def test_lesson_6_expectations():
|
||||
lesson = get_lesson(6)
|
||||
assert lesson is not None
|
||||
assert lesson.area == "expectations"
|
||||
|
||||
|
||||
def test_lesson_7_report():
|
||||
lesson = get_lesson(7)
|
||||
assert lesson is not None
|
||||
assert lesson.area == "report"
|
||||
|
||||
|
||||
def test_lessons_have_non_empty_fields():
|
||||
"""All lessons must have non-empty title, description, and instructions."""
|
||||
for lesson in LESSON_CATALOG:
|
||||
assert lesson.title.strip(), f"Lesson {lesson.step} has empty title"
|
||||
assert lesson.description.strip(), f"Lesson {lesson.step} has empty description"
|
||||
assert lesson.instructions.strip(), f"Lesson {lesson.step} has empty instructions"
|
||||
assert lesson.what_it_enables.strip(), f"Lesson {lesson.step} has empty what_it_enables"
|
||||
|
||||
|
||||
def test_lessons_have_possible_outcomes():
|
||||
"""All lessons must have at least one possible outcome."""
|
||||
for lesson in LESSON_CATALOG:
|
||||
assert len(lesson.possible_outcomes) >= 1, f"Lesson {lesson.step} has no outcomes"
|
||||
|
||||
|
||||
# ── Owner-safe validation ─────────────────────────────────────────────────
|
||||
|
||||
def test_forbidden_keywords_not_empty():
|
||||
"""FORBIDDEN_KEYWORDS must contain expected keywords."""
|
||||
assert "terminal" in FORBIDDEN_KEYWORDS
|
||||
assert "docker" in FORBIDDEN_KEYWORDS
|
||||
assert "nano" in FORBIDDEN_KEYWORDS
|
||||
assert "bash" in FORBIDDEN_KEYWORDS
|
||||
assert "sudo" in FORBIDDEN_KEYWORDS
|
||||
assert "shell" in FORBIDDEN_KEYWORDS
|
||||
|
||||
|
||||
def test_is_owner_safe_clean_text():
|
||||
assert is_owner_safe("Log in to your Vagaro account in your browser.") is True
|
||||
assert is_owner_safe("Choose a name for your assistant.") is True
|
||||
assert is_owner_safe("Share the API key with your operator.") is True
|
||||
|
||||
|
||||
def test_is_owner_safe_forbidden_text():
|
||||
assert is_owner_safe("Run docker-compose up") is False
|
||||
assert is_owner_safe("Open a terminal and type") is False
|
||||
assert is_owner_safe("Edit with nano") is False
|
||||
assert is_owner_safe("Execute the bash script") is False
|
||||
|
||||
|
||||
def test_is_owner_safe_multi_word_no_false_positive():
|
||||
"""Multi-word keywords must not false-positive on unrelated text."""
|
||||
# "brew install" should NOT match "brew installation" or "homebrew installed"
|
||||
assert is_owner_safe("We use homebrew installed packages") is True
|
||||
assert is_owner_safe("The brew installation completed") is True
|
||||
assert is_owner_safe("I will install the app manually") is True
|
||||
# "pip install" should NOT match "pip installed" or "install pip"
|
||||
assert is_owner_safe("pip installed successfully") is True
|
||||
assert is_owner_safe("install pip from the store") is True
|
||||
# "git clone" should NOT match "clone git" (reversed)
|
||||
assert is_owner_safe("clone git repository") is True
|
||||
# "docker run" should NOT match "docker running" — but "docker" alone
|
||||
# IS a single-word forbidden keyword, so we test the multi-word phrase
|
||||
# in isolation by checking the phrase itself doesn't match a variant:
|
||||
assert is_owner_safe("the container is running in background") is True
|
||||
# "make install" should NOT match "make installation"
|
||||
assert is_owner_safe("make installation directory") is True
|
||||
# "npm install" should NOT match "npm installed"
|
||||
assert is_owner_safe("npm installed globally") is True
|
||||
|
||||
|
||||
def test_is_owner_safe_multi_word_true_positive():
|
||||
"""Multi-word keywords must still match the exact phrase."""
|
||||
assert is_owner_safe("brew install python") is False
|
||||
assert is_owner_safe("pip install requests") is False
|
||||
assert is_owner_safe("npm install express") is False
|
||||
assert is_owner_safe("git clone https://example.com") is False
|
||||
assert is_owner_safe("docker run nginx") is False
|
||||
assert is_owner_safe("make install all") is False
|
||||
assert is_owner_safe("docker exec container") is False
|
||||
assert is_owner_safe("git push origin main") is False
|
||||
assert is_owner_safe("git pull origin main") is False
|
||||
|
||||
|
||||
def test_is_owner_safe_single_word_boundaries():
|
||||
"""Single-word keywords use word boundaries."""
|
||||
# "docker" should match standalone but not inside unrelated words
|
||||
assert is_owner_safe("Use docker to containerize") is False
|
||||
# "nano" should match standalone
|
||||
assert is_owner_safe("Edit with nano") is False
|
||||
# "bash" should match standalone
|
||||
assert is_owner_safe("Run in bash") is False
|
||||
|
||||
|
||||
def test_is_owner_safe_case_insensitive():
|
||||
assert is_owner_safe("Use DOCKER to run") is False
|
||||
assert is_owner_safe("Open TERMINAL") is False
|
||||
|
||||
|
||||
def test_validate_lesson_owner_safe_clean():
|
||||
"""All catalog lessons must pass owner-safe validation."""
|
||||
for lesson in LESSON_CATALOG:
|
||||
violations = validate_lesson_owner_safe(lesson)
|
||||
assert violations == [], (
|
||||
f"Lesson {lesson.step} ({lesson.title}) contains forbidden keywords: {violations}"
|
||||
)
|
||||
|
||||
|
||||
def test_validate_catalog_owner_safe():
|
||||
"""Full catalog validation must return empty dict."""
|
||||
violations = validate_catalog_owner_safe()
|
||||
assert violations == {}, f"Catalog has violations: {violations}"
|
||||
|
||||
|
||||
def test_validate_lesson_owner_safe_detects_forbidden():
|
||||
"""Validation correctly detects forbidden keywords."""
|
||||
bad_lesson = Lesson(
|
||||
step=99,
|
||||
title="Bad Lesson",
|
||||
description="Run docker-compose up in your terminal",
|
||||
instructions="Open bash and type sudo nano config.yml",
|
||||
what_it_enables="Nothing",
|
||||
)
|
||||
violations = validate_lesson_owner_safe(bad_lesson)
|
||||
assert "docker" in violations
|
||||
assert "terminal" in violations
|
||||
assert "bash" in violations
|
||||
assert "sudo" in violations
|
||||
assert "nano" in violations
|
||||
|
||||
|
||||
def test_validate_lesson_owner_safe_includes_possible_outcomes():
|
||||
"""Validation checks possible_outcomes field for forbidden keywords."""
|
||||
lesson_with_bad_outcome = Lesson(
|
||||
step=99,
|
||||
title="Good Lesson",
|
||||
description="Clean description",
|
||||
instructions="Clean instructions",
|
||||
what_it_enables="Clean enables",
|
||||
possible_outcomes=["connected", "docker"], # "docker" in outcomes
|
||||
)
|
||||
violations = validate_lesson_owner_safe(lesson_with_bad_outcome)
|
||||
assert "docker" in violations, "possible_outcomes should be checked"
|
||||
|
||||
|
||||
def test_validate_lesson_owner_safe_clean_outcomes():
|
||||
"""Validation passes when possible_outcomes are clean."""
|
||||
clean_lesson = Lesson(
|
||||
step=99,
|
||||
title="Good Lesson",
|
||||
description="Clean description",
|
||||
instructions="Clean instructions",
|
||||
what_it_enables="Clean enables",
|
||||
possible_outcomes=["connected", "skipped", "later", "error"],
|
||||
)
|
||||
violations = validate_lesson_owner_safe(clean_lesson)
|
||||
assert violations == [], f"Clean lesson should have no violations: {violations}"
|
||||
|
||||
|
||||
# ── Formatting ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_format_lesson_text_includes_step():
|
||||
lesson = get_lesson(1)
|
||||
text = format_lesson_text(lesson)
|
||||
assert "Step 1" in text
|
||||
|
||||
|
||||
def test_format_lesson_text_includes_title():
|
||||
lesson = get_lesson(1)
|
||||
text = format_lesson_text(lesson)
|
||||
assert lesson.title in text
|
||||
|
||||
|
||||
def test_format_lesson_text_includes_instructions():
|
||||
lesson = get_lesson(4)
|
||||
text = format_lesson_text(lesson)
|
||||
assert "What to do:" in text
|
||||
assert lesson.instructions in text
|
||||
|
||||
|
||||
def test_format_lesson_text_owner_safe():
|
||||
"""Formatted lesson text must be owner-safe."""
|
||||
for lesson in LESSON_CATALOG:
|
||||
text = format_lesson_text(lesson)
|
||||
assert is_owner_safe(text), (
|
||||
f"Lesson {lesson.step} formatted text contains forbidden keywords"
|
||||
)
|
||||
|
||||
|
||||
def test_format_all_lessons_text_owner_safe():
|
||||
"""Full lessons text must be owner-safe."""
|
||||
text = format_all_lessons_text()
|
||||
assert is_owner_safe(text)
|
||||
|
||||
|
||||
def test_format_all_lessons_text_includes_all_steps():
|
||||
text = format_all_lessons_text()
|
||||
for i in range(1, 8):
|
||||
assert f"Step {i}" in text
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Tests for lumina_skills.providers.setup.fixture_provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
|
||||
|
||||
from lumina_skills.setup.capability_report import (
|
||||
CapabilityReport,
|
||||
ConnectionStatus,
|
||||
)
|
||||
from lumina_skills.providers.setup.fixture_provider import (
|
||||
load_capability_fixture,
|
||||
load_fixture_metadata,
|
||||
)
|
||||
|
||||
# Paths to real fixture files.
|
||||
_FIXTURE_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "setup" / "capability_matrix.json"
|
||||
_ALL_CONNECTED_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "setup" / "capability_matrix_all_connected.json"
|
||||
_ERRORS_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "setup" / "capability_matrix_with_errors.json"
|
||||
|
||||
|
||||
def _make_fixture_file(tmp_path: pathlib.Path, data: dict) -> pathlib.Path:
|
||||
"""Write a fixture dict to a temp JSON file."""
|
||||
p = tmp_path / "test_fixture.json"
|
||||
p.write_text(json.dumps(data), encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
# ── load_capability_fixture ───────────────────────────────────────────────
|
||||
|
||||
def test_load_default_fixture():
|
||||
"""Load the default capability matrix fixture."""
|
||||
report = load_capability_fixture(_FIXTURE_PATH)
|
||||
assert isinstance(report, CapabilityReport)
|
||||
assert report.salon_name == "Lumina Hair Studio & Spa"
|
||||
assert report.is_fixture is True
|
||||
assert len(report.capabilities) > 0
|
||||
|
||||
|
||||
def test_load_fixture_statuses():
|
||||
"""Fixture statuses are correctly parsed."""
|
||||
report = load_capability_fixture(_FIXTURE_PATH)
|
||||
statuses = {(c.area, c.provider): c.status for c in report.capabilities}
|
||||
assert statuses[("identity", "assistant_name")] == ConnectionStatus.CONNECTED
|
||||
assert statuses[("channels", "whatsapp")] == ConnectionStatus.CONNECTED
|
||||
assert statuses[("channels", "email")] == ConnectionStatus.SKIPPED
|
||||
assert statuses[("channels", "telegram")] == ConnectionStatus.LATER
|
||||
assert statuses[("scheduling", "vagaro")] == ConnectionStatus.OFFLINE
|
||||
|
||||
|
||||
def test_load_fixture_all_connected():
|
||||
"""All-connected fixture has all CONNECTED statuses."""
|
||||
report = load_capability_fixture(_ALL_CONNECTED_PATH)
|
||||
assert report.all_connected() is True
|
||||
assert report.is_fixture is True
|
||||
|
||||
|
||||
def test_load_fixture_with_errors():
|
||||
"""Error fixture has ERROR statuses and reports has_errors."""
|
||||
report = load_capability_fixture(_ERRORS_PATH)
|
||||
assert report.has_errors() is True
|
||||
assert report.error_count() >= 1
|
||||
|
||||
|
||||
def test_load_fixture_is_fixture_flag():
|
||||
"""Fixture reports always have is_fixture=True."""
|
||||
report = load_capability_fixture(_FIXTURE_PATH)
|
||||
assert report.is_fixture is True
|
||||
|
||||
|
||||
def test_load_fixture_missing_file(tmp_path: pathlib.Path):
|
||||
"""FileNotFoundError for missing fixture."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_capability_fixture(tmp_path / "nonexistent.json")
|
||||
|
||||
|
||||
def test_load_fixture_empty_capabilities(tmp_path: pathlib.Path):
|
||||
"""Empty capabilities list returns empty report."""
|
||||
data = {
|
||||
"salon_name": "Empty Salon",
|
||||
"is_fixture": True,
|
||||
"capabilities": [],
|
||||
}
|
||||
path = _make_fixture_file(tmp_path, data)
|
||||
report = load_capability_fixture(path)
|
||||
assert len(report.capabilities) == 0
|
||||
assert report.salon_name == "Empty Salon"
|
||||
|
||||
|
||||
def test_load_fixture_unknown_status_raises(tmp_path: pathlib.Path):
|
||||
"""Unknown status string raises ValueError."""
|
||||
data = {
|
||||
"salon_name": "Test",
|
||||
"is_fixture": True,
|
||||
"capabilities": [{
|
||||
"area": "channels",
|
||||
"provider": "whatsapp",
|
||||
"status": "typo_status",
|
||||
}],
|
||||
}
|
||||
path = _make_fixture_file(tmp_path, data)
|
||||
with pytest.raises(ValueError, match="Unknown capability status"):
|
||||
load_capability_fixture(path)
|
||||
|
||||
|
||||
def test_load_fixture_missing_area_field(tmp_path: pathlib.Path):
|
||||
"""Missing 'area' field raises ValueError with clear message."""
|
||||
data = {
|
||||
"salon_name": "Test",
|
||||
"is_fixture": True,
|
||||
"capabilities": [{
|
||||
"provider": "whatsapp",
|
||||
"status": "connected",
|
||||
# Missing "area"
|
||||
}],
|
||||
}
|
||||
path = _make_fixture_file(tmp_path, data)
|
||||
with pytest.raises(ValueError, match="Capability entry 0 missing required field 'area'"):
|
||||
load_capability_fixture(path)
|
||||
|
||||
|
||||
def test_load_fixture_missing_provider_field(tmp_path: pathlib.Path):
|
||||
"""Missing 'provider' field raises ValueError with clear message."""
|
||||
data = {
|
||||
"salon_name": "Test",
|
||||
"is_fixture": True,
|
||||
"capabilities": [{
|
||||
"area": "channels",
|
||||
"status": "connected",
|
||||
# Missing "provider"
|
||||
}],
|
||||
}
|
||||
path = _make_fixture_file(tmp_path, data)
|
||||
with pytest.raises(ValueError, match="Capability entry 0 missing required field 'provider'"):
|
||||
load_capability_fixture(path)
|
||||
|
||||
|
||||
def test_load_fixture_missing_status_field(tmp_path: pathlib.Path):
|
||||
"""Missing 'status' field raises ValueError with clear message."""
|
||||
data = {
|
||||
"salon_name": "Test",
|
||||
"is_fixture": True,
|
||||
"capabilities": [{
|
||||
"area": "channels",
|
||||
"provider": "whatsapp",
|
||||
# Missing "status"
|
||||
}],
|
||||
}
|
||||
path = _make_fixture_file(tmp_path, data)
|
||||
with pytest.raises(ValueError, match="Capability entry 0 missing required field 'status'"):
|
||||
load_capability_fixture(path)
|
||||
|
||||
|
||||
def test_load_fixture_missing_field_reports_index(tmp_path: pathlib.Path):
|
||||
"""Missing field error message includes the entry index."""
|
||||
data = {
|
||||
"salon_name": "Test",
|
||||
"is_fixture": True,
|
||||
"capabilities": [
|
||||
{
|
||||
"area": "channels",
|
||||
"provider": "whatsapp",
|
||||
"status": "connected",
|
||||
},
|
||||
{
|
||||
"area": "books",
|
||||
# Missing "provider" and "status" in entry 1
|
||||
},
|
||||
],
|
||||
}
|
||||
path = _make_fixture_file(tmp_path, data)
|
||||
with pytest.raises(ValueError, match="Capability entry 1 missing required field 'provider'"):
|
||||
load_capability_fixture(path)
|
||||
|
||||
|
||||
def test_load_fixture_malformed_json(tmp_path: pathlib.Path):
|
||||
"""ValueError for invalid JSON."""
|
||||
p = tmp_path / "bad.json"
|
||||
p.write_text("{not valid json}", encoding="utf-8")
|
||||
with pytest.raises(ValueError):
|
||||
load_capability_fixture(p)
|
||||
|
||||
|
||||
def test_load_fixture_default_salon_name(tmp_path: pathlib.Path):
|
||||
"""Missing salon_name defaults to 'Unknown Salon'."""
|
||||
data = {
|
||||
"is_fixture": True,
|
||||
"capabilities": [],
|
||||
}
|
||||
path = _make_fixture_file(tmp_path, data)
|
||||
report = load_capability_fixture(path)
|
||||
assert report.salon_name == "Unknown Salon"
|
||||
|
||||
|
||||
def test_load_fixture_details():
|
||||
"""Details field is loaded from fixture."""
|
||||
report = load_capability_fixture(_FIXTURE_PATH)
|
||||
details = {(c.area, c.provider): c.details for c in report.capabilities}
|
||||
assert "fixture" in details[("scheduling", "vagaro")].lower() or "not yet" in details[("scheduling", "vagaro")].lower()
|
||||
|
||||
|
||||
# ── Fixture labeling: offline/fixture never silent as live ─────────────────
|
||||
|
||||
def test_fixture_never_silent_as_live():
|
||||
"""Fixture reports must have is_fixture=True — never silent as live."""
|
||||
report = load_capability_fixture(_FIXTURE_PATH)
|
||||
assert report.is_fixture is True, "Fixture report must always be labeled as fixture"
|
||||
|
||||
report2 = load_capability_fixture(_ALL_CONNECTED_PATH)
|
||||
assert report2.is_fixture is True
|
||||
|
||||
report3 = load_capability_fixture(_ERRORS_PATH)
|
||||
assert report3.is_fixture is True
|
||||
|
||||
|
||||
def test_fixture_explicit_false_still_respected():
|
||||
"""If fixture explicitly sets is_fixture=false, it is loaded as-is."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = pathlib.Path(tmp)
|
||||
data = {
|
||||
"salon_name": "Test",
|
||||
"is_fixture": False,
|
||||
"capabilities": [{
|
||||
"area": "channels",
|
||||
"provider": "whatsapp",
|
||||
"status": "connected",
|
||||
}],
|
||||
}
|
||||
path = _make_fixture_file(tmp_path, data)
|
||||
report = load_capability_fixture(path)
|
||||
# The loader respects the explicit value.
|
||||
assert report.is_fixture is False
|
||||
|
||||
|
||||
# ── load_fixture_metadata ─────────────────────────────────────────────────
|
||||
|
||||
def test_load_metadata():
|
||||
meta = load_fixture_metadata(_FIXTURE_PATH)
|
||||
assert meta["salon_name"] == "Lumina Hair Studio & Spa"
|
||||
assert meta["is_fixture"] is True
|
||||
assert "generated_at" in meta
|
||||
assert "note" in meta
|
||||
|
||||
|
||||
def test_load_metadata_missing_file(tmp_path: pathlib.Path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_fixture_metadata(tmp_path / "nonexistent.json")
|
||||
Reference in New Issue
Block a user