Files
Tatami/tests/test_classes.py
T
lars 4da624bfcf Fix route optimizer and group assignment; add test suite
Route building / optimization (tatami_masterplan.py):
- fast_total_time was permutation-invariant: it applied get_courses to
  group *values* instead of slots and ignored the permutation, so every
  ordering scored identically and the annealing optimized nothing. It now
  maps each rotation slot to its assigned group via the permutation.
- Replaced the broken next_permutation/simulated_annealing (enumerated n!
  orderings per iteration, fed unnormalized Boltzmann weights to
  np.random.choice -> ValueError, and returned the last random sample) with
  a standard neighbor-swap annealer that tracks and returns the best
  solution and handles <2 slots.
- Convert the reduced Timedelta matrix to float seconds before annealing
  (np.exp can't operate on Timedelta).

Group building (tatami_masterplan.py):
- assign_courses set each group's hosts (sorted by course) before all
  courses were assigned, so hosts whose course was still None got
  mis-ordered. Assign all courses first, then wire up hosts.
- get_masterplan no longer mutates the caller's participant list.

classes.py:
- Narrow casts on distance-matrix lookups to satisfy the mypy gate
  (pre-existing failures).

Tests:
- Add pytest suite (74 tests) covering the rotation topology, route cost
  and optimization, the domain model, the masterplan pipeline, and the
  Routes API wrapper (HTTP mocked). The cost cross-check caught the
  assign_courses ordering bug above.
2026-06-19 14:19:00 +02:00

142 lines
5.3 KiB
Python

"""Tests for the core domain model: Participant and Group."""
import datetime as dt
import numpy as np
import pytest
from tatami.classes import Group, Participant
from conftest import make_participants, make_timedelta_matrix
class TestParticipant:
def test_uuid_is_unique(self):
a = Participant("A", "addr", "", 5, "")
b = Participant("A", "addr", "", 5, "")
assert a.uuid != b.uuid
@pytest.mark.parametrize(
"kitchen_size,minutes",
[(10, 0), (7, 9), (0, 30), (5, 15)],
)
def test_penalty_scales_with_kitchen_size(self, kitchen_size, minutes):
p = Participant("A", "addr", "", kitchen_size, "")
assert p.get_penalty() == dt.timedelta(minutes=minutes)
def test_after_party_time_combines_penalty_and_travel(self):
people = make_participants(2, kitchen_sizes=[7.0, 10.0])
# 600s travel between the two participants.
matrix = make_timedelta_matrix(people, np.array([[0, 600], [600, 0]]))
after_party = Group(members=[people[1]])
result = people[0].get_after_party_time(matrix, after_party)
assert result == dt.timedelta(minutes=9) + dt.timedelta(seconds=600)
def test_dict_roundtrip_fields(self):
p = Participant("Alice", "addr", "555", 8, "peanuts")
d = p.dict()
assert d == {
"uuid": p.uuid,
"name": "Alice",
"address": "addr",
"phone": "555",
"kitchen_size": 8,
"allergies": "peanuts",
}
class TestGroup:
def test_default_main_member_is_first(self):
people = make_participants(3)
group = Group(members=people)
assert group.main_member is people[0]
def test_explicit_main_member_index(self):
people = make_participants(3)
group = Group(members=people, main_member=2)
assert group.main_member is people[2]
def test_add_member_appends_without_changing_main(self):
people = make_participants(2)
group = Group(members=[people[0]])
group.add_member(people[1])
assert people[1] in group.members
assert group.main_member is people[0]
def test_add_member_can_promote_to_main(self):
people = make_participants(2)
group = Group(members=[people[0]])
group.add_member(people[1], main_member=True)
assert group.main_member is people[1]
def test_set_hosts_sorts_by_course(self):
people = make_participants(3)
groups = [Group(members=[p]) for p in people]
groups[0].set_course("dessert")
groups[1].set_course("starter")
groups[2].set_course("main")
host = Group(members=[make_participants(1)[0]])
host.set_hosts([groups[0], groups[1], groups[2]])
assert [g.course for g in host.hosts] == ["starter", "main", "dessert"]
def test_get_total_time_sums_legs_and_penalties(self):
# Three host groups (penalties 0) + after party; verify the summed route.
people = make_participants(4, kitchen_sizes=[10, 10, 10, 10])
seconds = np.array(
[
[0, 100, 0, 0, 50],
[0, 0, 200, 0, 0],
[0, 0, 0, 0, 300],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
]
)
# build matrix including an after party participant
after_party_p = make_participants(1)[0]
all_people = people + [after_party_p]
full = make_timedelta_matrix(all_people, seconds)
starter = Group(members=[people[0]])
starter.set_course("starter")
main = Group(members=[people[1]])
main.set_course("main")
dessert = Group(members=[people[2]])
dessert.set_course("dessert")
after_party = Group(members=[after_party_p])
visitor = Group(members=[people[3]])
visitor.set_hosts([starter, main, dessert])
total = visitor.get_total_time(full, after_party)
# legs: starter->main (100) + main->dessert (200) + dessert->afterparty (300)
assert total == dt.timedelta(seconds=600)
def test_get_total_time_raises_without_hosts(self):
group = Group(members=make_participants(1))
after_party = Group(members=make_participants(1))
with pytest.raises(ValueError):
group.get_total_time(None, after_party)
def test_get_guests_collects_visiting_members(self):
people = make_participants(4)
host = Group(members=[people[0]])
guest_a = Group(members=[people[1]])
guest_b = Group(members=[people[2], people[3]])
# get_guests returns early when the host has no hosts of its own, so give it
# a (self-)host as it would have in a real plan.
host.set_hosts([host])
guest_a.set_hosts([host])
guest_b.set_hosts([host])
guests = host.get_guests([guest_a, guest_b])
guest_uuids = {p.uuid for p in guests}
assert guest_uuids == {people[1].uuid, people[2].uuid, people[3].uuid}
def test_dict_serializes_uuids(self):
people = make_participants(2)
group = Group(members=people)
host = Group(members=make_participants(1))
host.set_course("starter")
group.set_hosts([host])
d = group.dict()
assert d["main_member"] == people[0].uuid
assert d["members"] == [p.uuid for p in people]
assert d["hosts"] == [host.uuid]