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,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