15f6a6c713
Add operator health checks (make doctor) wrapping platform CLIs, and the fixtures-only daily board skill library with unit tests (make verify).
307 lines
10 KiB
Python
307 lines
10 KiB
Python
"""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
|