Implement S6 doctor and A1 daily-board with fixtures.

Add operator health checks (make doctor) wrapping platform CLIs, and the fixtures-only daily board skill library with unit tests (make verify).
This commit is contained in:
Ty
2026-07-27 12:41:40 -07:00
parent 0198ab6881
commit 15f6a6c713
24 changed files with 2011 additions and 40 deletions
+19 -9
View File
@@ -1,12 +1,22 @@
# Tests (scaffold)
# Tests
**Status:** Structure only — no product tests until **build**.
| Path | Purpose | Status |
|------|---------|--------|
| `unit/` | Deterministic domain/skill logic (no live model required) | ✅ Partial |
| `contract/` | Provider/MCP allow-deny contracts | ⏳ |
| `integration/` | Optional cheap-model dialogue paths | ⏳ |
| `fixtures/` | Test-only fixtures | ⏳ |
| Path | Purpose |
|------|---------|
| `unit/` | Deterministic domain/skill logic (no live model required) |
| `contract/` | Provider/MCP allow-deny contracts |
| `integration/` | Optional cheap-model dialogue paths |
| `fixtures/` | Test-only fixtures |
## Running tests
Design boundary: [design/det-vs-inf.md](../design/det-vs-inf.md).
```bash
# All unit tests
python -m pytest tests/unit/ -v
# Specific test file
python -m pytest tests/unit/test_board_builder.py -v
```
## Design boundary
[design/det-vs-inf.md](../design/det-vs-inf.md) — unit tests cover the deterministic column without a live model.
+1
View File
@@ -0,0 +1 @@
"""Unit tests for Salon_Assistant deterministic logic."""
+306
View File
@@ -0,0 +1,306 @@
"""Tests for the deterministic board builder."""
from __future__ import annotations
from datetime import datetime, time
import pytest
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
from lumina_skills.domain import (
Appointment,
AppointmentStatus,
DayBoard,
Gap,
)
from lumina_skills.board_builder import (
build_board,
format_board_text,
)
def _apt(
apt_id: str,
start_h: int,
start_m: int,
end_h: int,
end_m: int,
client: str = "Client",
service: str = "Service",
staff: str = "Staff",
status: AppointmentStatus = AppointmentStatus.CONFIRMED,
needs_confirmation: bool = False,
notes: str = "",
) -> Appointment:
"""Helper to create an Appointment quickly."""
return Appointment(
appointment_id=apt_id,
start_time=datetime(2026, 7, 28, start_h, start_m),
end_time=datetime(2026, 7, 28, end_h, end_m),
client_name=client,
service_name=service,
staff_name=staff,
status=status,
notes=notes,
needs_confirmation=needs_confirmation,
)
# ── build_board ────────────────────────────────────────────────────────────
def test_build_board_basic():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon", source="fixtures")
assert len(board.appointments) == 2
assert board.total_booked_minutes == 120
assert board.is_offline is True
def test_build_board_excludes_cancelled():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire", status=AppointmentStatus.CANCELLED),
_apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert len(board.appointments) == 2 # Cancelled excluded
assert board.appointments[0].appointment_id == "A1"
assert board.appointments[1].appointment_id == "A3"
def test_build_board_sorts_by_time():
apts = [
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"),
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert board.appointments[0].appointment_id == "A1"
assert board.appointments[1].appointment_id == "A2"
def test_build_board_confirmation_flags():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", needs_confirmation=False),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire", needs_confirmation=True),
_apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire", needs_confirmation=True),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert len(board.needs_confirmation) == 2
assert board.needs_confirmation[0].appointment_id == "A2"
assert board.needs_confirmation[1].appointment_id == "A3"
def test_build_board_gaps_between_apts():
"""Gap between two appointments on the same staff."""
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert len(board.gaps) == 1
gap = board.gaps[0]
assert gap.start_time == time(10, 0)
assert gap.end_time == time(11, 0)
assert gap.duration_minutes == 60
assert gap.staff_name == "Claire"
def test_build_board_no_small_gaps():
"""Gaps under 30 minutes are not included."""
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 10, 15, 11, 15, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
# 15-minute gap should be excluded.
assert len(board.gaps) == 0
def test_build_board_overlapping_appointments_warns():
"""Overlapping appointments emit a warning and skip the negative gap."""
apts = [
_apt("A1", 9, 0, 10, 30, "Alice", "Cut", "Claire"),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"), # starts 30 min before A1 ends
]
with pytest.warns(UserWarning, match="Overlapping appointments"):
board = build_board(apts, "2026-07-28", "Test Salon")
# The negative gap should not appear in the board.
assert all(g.duration_minutes >= 0 for g in board.gaps)
# Both appointments still appear (overlap is a data issue, not a filter).
assert len(board.appointments) == 2
def test_build_board_boundary_gaps():
"""Gaps from open→first and last→close when business_hours provided."""
apts = [
_apt("A1", 10, 0, 11, 0, "Alice", "Cut", "Claire"),
_apt("A2", 15, 0, 16, 0, "Bob", "Color", "Claire"),
]
board = build_board(
apts, "2026-07-28", "Test Salon",
business_hours={"open": "09:00", "close": "18:00"},
)
# Should have: 09:00-10:00 (60 min), 11:00-15:00 (240 min), 16:00-18:00 (120 min)
assert len(board.gaps) == 3
assert board.gaps[0].start_time == time(9, 0)
assert board.gaps[0].end_time == time(10, 0)
assert board.gaps[2].start_time == time(16, 0)
assert board.gaps[2].end_time == time(18, 0)
def test_build_board_multi_staff_gaps():
"""Gaps are computed per staff member."""
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 9, 0, 10, 0, "Bob", "Color", "Maya"),
_apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire"),
_apt("A4", 11, 0, 12, 0, "Dave", "Trim", "Maya"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
# Each staff has a 60-min gap.
assert len(board.gaps) == 2
staff_gaps = {g.staff_name: g.duration_minutes for g in board.gaps}
assert staff_gaps["Claire"] == 60
assert staff_gaps["Maya"] == 60
def test_build_board_invalid_business_hours_warns():
"""Malformed business_hours values warn and skip boundary gaps."""
apts = [
_apt("A1", 10, 0, 11, 0, "Alice", "Cut", "Claire"),
]
with pytest.warns(UserWarning, match="Invalid business_hours"):
board = build_board(
apts, "2026-07-28", "Test Salon",
business_hours={"open": "nine", "close": "18:00"},
)
# Only the close boundary gap should appear (open was invalid).
assert len(board.gaps) == 1
assert board.gaps[0].start_time == time(11, 0)
assert board.gaps[0].end_time == time(18, 0)
def test_build_board_empty():
board = build_board([], "2026-07-28", "Empty Salon")
assert len(board.appointments) == 0
assert len(board.gaps) == 0
assert board.total_booked_minutes == 0
assert board.total_gap_minutes == 0
def test_build_board_source_labeling():
"""Source is correctly set and is_offline derived."""
board = build_board([], "2026-07-28", "Test", source="fixtures")
assert board.source == "fixtures"
assert board.is_offline is True
board2 = build_board([], "2026-07-28", "Test", source="offline")
assert board2.is_offline is True
board3 = build_board([], "2026-07-28", "Test", source="vagaro")
assert board3.is_offline is False
def test_build_board_totals():
apts = [
_apt("A1", 9, 0, 10, 30, "Alice", "Cut", "Claire"), # 90 min
_apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"), # 60 min
]
board = build_board(
apts, "2026-07-28", "Test Salon",
business_hours={"open": "09:00", "close": "18:00"},
)
assert board.total_booked_minutes == 150
# Gaps: 10:30-11:00 (30 min), 12:00-18:00 (360 min)
assert board.total_gap_minutes == 390
# ── format_board_text ──────────────────────────────────────────────────────
def test_format_text_includes_offline_label():
board = DayBoard(
date="2026-07-28",
salon_name="Test Salon",
source="fixtures",
is_offline=True,
)
text = format_board_text(board)
assert "FIXTURE DATA" in text
def test_format_text_includes_appointments():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Alice" in text
assert "Cut" in text
assert "Claire" in text
assert "09:00" in text
def test_format_text_includes_gaps():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Gaps" in text
assert "60 min" in text
def test_format_text_includes_confirmation_flags():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", needs_confirmation=True),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "CONFIRM" in text
assert "Needs Confirmation" in text
def test_format_text_includes_summary():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Summary" in text
assert "60 min" in text
def test_format_text_no_appointments():
board = DayBoard(
date="2026-07-28",
salon_name="Empty Salon",
source="fixtures",
is_offline=True,
)
text = format_board_text(board)
assert "No appointments" in text
def test_format_text_no_gaps():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "No significant gaps" in text
def test_format_text_notes():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", notes="Allergic to ammonia"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Allergic to ammonia" in text
+182
View File
@@ -0,0 +1,182 @@
"""Tests for lumina_skills.domain types."""
from __future__ import annotations
import json
from datetime import datetime, time
import pytest
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
from lumina_skills.domain import (
Appointment,
AppointmentStatus,
DayBoard,
Gap,
)
# ── Appointment ────────────────────────────────────────────────────────────
def test_appointment_duration():
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 30),
client_name="Elena Rossi",
service_name="Balayage + Cut",
staff_name="Claire Bennett",
status=AppointmentStatus.CONFIRMED,
)
assert apt.duration_minutes() == 90
def test_appointment_duration_one_hour():
apt = Appointment(
appointment_id="APT-002",
start_time=datetime(2026, 7, 28, 13, 0),
end_time=datetime(2026, 7, 28, 14, 0),
client_name="Chris Nguyen",
service_name="Men's Cut",
staff_name="Claire Bennett",
status=AppointmentStatus.PENDING,
)
assert apt.duration_minutes() == 60
def test_appointment_time_only():
apt = Appointment(
appointment_id="APT-003",
start_time=datetime(2026, 7, 28, 11, 15),
end_time=datetime(2026, 7, 28, 12, 45),
client_name="Jasmine Patel",
service_name="Root Touch-Up",
staff_name="Maya Torres",
status=AppointmentStatus.CONFIRMED,
)
assert apt.start_time_only() == time(11, 15)
assert apt.end_time_only() == time(12, 45)
def test_appointment_frozen():
"""Appointment is immutable."""
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 0),
client_name="Test",
service_name="Test",
staff_name="Test",
status=AppointmentStatus.CONFIRMED,
)
with pytest.raises(Exception): # FrozenInstanceError
apt.client_name = "Hacker"
def test_appointment_needs_confirmation_default():
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 0),
client_name="Test",
service_name="Test",
staff_name="Test",
status=AppointmentStatus.PENDING,
)
assert apt.needs_confirmation is False
def test_appointment_status_enum():
assert AppointmentStatus.CONFIRMED.value == "confirmed"
assert AppointmentStatus.PENDING.value == "pending"
assert AppointmentStatus.CANCELLED.value == "cancelled"
assert AppointmentStatus.COMPLETED.value == "completed"
assert AppointmentStatus.NO_SHOW.value == "no_show"
# ── Gap ────────────────────────────────────────────────────────────────────
def test_gap_creation():
gap = Gap(
start_time=time(12, 0),
end_time=time(13, 30),
duration_minutes=90,
staff_name="Claire Bennett",
)
assert gap.duration_minutes == 90
assert gap.staff_name == "Claire Bennett"
def test_gap_with_references():
gap = Gap(
start_time=time(12, 0),
end_time=time(13, 0),
duration_minutes=60,
staff_name="Claire Bennett",
preceding_appointment_id="APT-001",
following_appointment_id="APT-002",
)
assert gap.preceding_appointment_id == "APT-001"
assert gap.following_appointment_id == "APT-002"
# ── DayBoard ───────────────────────────────────────────────────────────────
def test_dayboard_to_dict():
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 0),
client_name="Elena Rossi",
service_name="Cut",
staff_name="Claire Bennett",
status=AppointmentStatus.CONFIRMED,
needs_confirmation=False,
)
board = DayBoard(
date="2026-07-28",
salon_name="Lumina Hair Studio & Spa",
source="fixtures",
is_offline=True,
appointments=[apt],
gaps=[],
needs_confirmation=[],
total_booked_minutes=60,
total_gap_minutes=0,
)
d = board.to_dict()
assert d["date"] == "2026-07-28"
assert d["salon_name"] == "Lumina Hair Studio & Spa"
assert d["source"] == "fixtures"
assert d["is_offline"] is True
assert len(d["appointments"]) == 1
assert d["appointments"][0]["client"] == "Elena Rossi"
assert d["total_booked_minutes"] == 60
def test_dayboard_to_dict_serializable():
"""to_dict output must be JSON-serializable."""
board = DayBoard(
date="2026-07-28",
salon_name="Test Salon",
source="fixtures",
is_offline=True,
)
d = board.to_dict()
# Should not raise.
json.dumps(d)
def test_dayboard_empty():
board = DayBoard(
date="2026-07-28",
salon_name="Empty Salon",
source="offline",
is_offline=True,
)
assert len(board.appointments) == 0
assert len(board.gaps) == 0
assert board.total_booked_minutes == 0
+156
View File
@@ -0,0 +1,156 @@
"""Tests for the scheduling 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.domain import Appointment, AppointmentStatus
from lumina_skills.providers.scheduling.fixture_provider import (
load_fixtures,
load_fixture_metadata,
)
# Path to the real fixture file.
_FIXTURE_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "scheduling" / "claire_bennett_2026-07-28.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_fixtures ──────────────────────────────────────────────────────────
def test_load_real_fixture():
"""Load the Claire Bennett fixture file."""
apts = load_fixtures(_FIXTURE_PATH)
assert len(apts) == 7 # 7 appointments in the fixture
assert all(isinstance(a, Appointment) for a in apts)
def test_load_fixture_statuses():
"""Fixture statuses are correctly parsed."""
apts = load_fixtures(_FIXTURE_PATH)
statuses = {a.appointment_id: a.status for a in apts}
assert statuses["APT-001"] == AppointmentStatus.CONFIRMED
assert statuses["APT-002"] == AppointmentStatus.PENDING
assert statuses["APT-007"] == AppointmentStatus.CANCELLED
def test_load_fixture_needs_confirmation():
"""needs_confirmation flag is loaded from fixture."""
apts = load_fixtures(_FIXTURE_PATH)
flags = {a.appointment_id: a.needs_confirmation for a in apts}
assert flags["APT-001"] is False
assert flags["APT-002"] is True
assert flags["APT-004"] is True
def test_load_fixture_notes():
"""Notes are loaded from fixture."""
apts = load_fixtures(_FIXTURE_PATH)
notes = {a.appointment_id: a.notes for a in apts}
assert "ammonia" in notes["APT-001"].lower()
assert "Wedding" in notes["APT-002"]
def test_load_fixture_missing_file(tmp_path: pathlib.Path):
"""FileNotFoundError for missing fixture."""
with pytest.raises(FileNotFoundError):
load_fixtures(tmp_path / "nonexistent.json")
def test_load_fixture_empty_appointments(tmp_path: pathlib.Path):
"""Empty appointments list returns empty list."""
data = {"salon_name": "Test", "date": "2026-01-01", "appointments": []}
path = _make_fixture_file(tmp_path, data)
apts = load_fixtures(path)
assert apts == []
def test_load_fixture_default_status(tmp_path: pathlib.Path):
"""Missing status field defaults to PENDING (via .get default)."""
data = {
"appointments": [{
"id": "APT-X",
"start": "2026-01-01T09:00:00",
"end": "2026-01-01T10:00:00",
"client_name": "Test",
"service_name": "Test",
"staff_name": "Test",
# No status field.
}]
}
path = _make_fixture_file(tmp_path, data)
apts = load_fixtures(path)
assert apts[0].status == AppointmentStatus.PENDING
def test_load_fixture_unknown_status_raises(tmp_path: pathlib.Path):
"""Unknown status string raises ValueError."""
data = {
"appointments": [{
"id": "APT-X",
"start": "2026-01-01T09:00:00",
"end": "2026-01-01T10:00:00",
"client_name": "Test",
"service_name": "Test",
"staff_name": "Test",
"status": "typo_status",
}]
}
path = _make_fixture_file(tmp_path, data)
with pytest.raises(ValueError, match="Unknown appointment status"):
load_fixtures(path)
def test_load_fixture_default_needs_confirmation(tmp_path: pathlib.Path):
"""Missing needs_confirmation defaults to False."""
data = {
"appointments": [{
"id": "APT-X",
"start": "2026-01-01T09:00:00",
"end": "2026-01-01T10:00:00",
"client_name": "Test",
"service_name": "Test",
"staff_name": "Test",
"status": "confirmed",
# No needs_confirmation field.
}]
}
path = _make_fixture_file(tmp_path, data)
apts = load_fixtures(path)
assert apts[0].needs_confirmation is False
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_fixtures(p)
# ── load_fixture_metadata ─────────────────────────────────────────────────
def test_load_metadata():
meta = load_fixture_metadata(_FIXTURE_PATH)
assert meta["salon_name"] == "Lumina Hair Studio & Spa"
assert meta["date"] == "2026-07-28"
assert meta["business_hours"]["open"] == "09:00"
assert meta["business_hours"]["close"] == "18:00"
assert len(meta["staff"]) == 2
def test_load_metadata_missing_file(tmp_path: pathlib.Path):
with pytest.raises(FileNotFoundError):
load_fixture_metadata(tmp_path / "nonexistent.json")