Files
Tatami/tests/test_traveltimes.py
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

122 lines
4.0 KiB
Python

"""Tests for the Google Routes API wrapper (HTTP layer mocked)."""
import datetime as dt
import numpy as np
import pandas as pd
import pytest
import tatami.traveltimes as tt
from tatami.classes import Group
from tatami.traveltimes import (
get_distance_matrix,
get_participant_distance_matrix,
reduce_distance_matrix,
)
from conftest import make_participants, make_timedelta_matrix
class FakeResponse:
def __init__(self, status_code, json_data=None, text=""):
self.status_code = status_code
self._json = json_data
self.text = text
def json(self):
return self._json
def route_matrix_payload(durations_seconds):
"""Build a computeRouteMatrix-style flat list of elements from an n x n array."""
payload = []
n = len(durations_seconds)
for i in range(n):
for j in range(n):
payload.append(
{
"originIndex": i,
"destinationIndex": j,
"duration": f"{int(durations_seconds[i][j])}s",
"distanceMeters": int(durations_seconds[i][j]) * 4,
}
)
return payload
@pytest.fixture
def mock_post(monkeypatch):
"""Patch requests.post; returns a setter for the desired response."""
state = {}
def fake_post(url, json=None, headers=None):
return state["response"]
monkeypatch.setattr(tt.requests, "post", fake_post)
def set_response(response):
state["response"] = response
return set_response
class TestGetDistanceMatrix:
def test_builds_duration_matrix(self, mock_post):
durations = np.array([[0, 120], [300, 0]])
mock_post(FakeResponse(200, route_matrix_payload(durations)))
matrix = get_distance_matrix(["a", "b"])
assert matrix.shape == (2, 2)
assert matrix.loc[0, 1] == pd.to_timedelta("120s")
assert matrix.loc[1, 0] == pd.to_timedelta("300s")
def test_distance_meters_value(self, mock_post):
durations = np.array([[0, 120], [300, 0]])
mock_post(FakeResponse(200, route_matrix_payload(durations)))
matrix = get_distance_matrix(["a", "b"], value="distanceMeters")
assert matrix.loc[0, 1] == 480.0
def test_empty_addresses_raises(self):
with pytest.raises(ValueError):
get_distance_matrix([])
def test_non_200_raises(self, mock_post):
mock_post(FakeResponse(500, text="boom"))
with pytest.raises(Exception, match="boom"):
get_distance_matrix(["a", "b"])
def test_invalid_value_raises(self, mock_post):
durations = np.array([[0, 120], [300, 0]])
mock_post(FakeResponse(200, route_matrix_payload(durations)))
with pytest.raises(ValueError):
get_distance_matrix(["a", "b"], value="bogus")
class TestGetParticipantDistanceMatrix:
def test_labels_axes_with_uuids(self, mock_post):
participants = make_participants(2)
durations = np.array([[0, 120], [300, 0]])
mock_post(FakeResponse(200, route_matrix_payload(durations)))
matrix = get_participant_distance_matrix(participants)
uuids = [p.uuid for p in participants]
assert list(matrix.index) == uuids
assert list(matrix.columns) == uuids
class TestReduceDistanceMatrix:
def test_selects_main_members_and_adds_penalty(self):
# group 0 main member has kitchen_size 7 -> 9 minute (540s) penalty.
people = make_participants(2, kitchen_sizes=[7.0, 10.0])
seconds = np.array([[0, 100], [200, 0]])
matrix = make_timedelta_matrix(people, seconds)
groups = [Group(members=[people[0]]), Group(members=[people[1]])]
reduced = reduce_distance_matrix(matrix, groups)
u0, u1 = people[0].uuid, people[1].uuid
assert list(reduced.index) == [u0, u1]
# Penalty added to every entry of group 0's row.
assert reduced.loc[u0, u1] == dt.timedelta(seconds=100) + dt.timedelta(
minutes=9
)
# Group 1 has no penalty (kitchen_size 10).
assert reduced.loc[u1, u0] == dt.timedelta(seconds=200)