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:
Ty
2026-07-27 13:10:57 -07:00
parent 69b67498ff
commit 76df8b7ab6
16 changed files with 1955 additions and 7 deletions
+300
View File
@@ -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}"
+297
View File
@@ -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
+254
View File
@@ -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")