Files
Salon_Assistant/tests/unit/test_board_builder.py
T
Ty 15f6a6c713 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).
2026-07-27 12:41:40 -07:00

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