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