File size: 5,149 Bytes
cca17f3
 
bd4540e
 
1f0fbfd
 
bd4540e
 
 
 
1f0fbfd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
from datetime import datetime

from fastapi.testclient import TestClient

from birdie.api import _counts, _status, create_app, mount_mobile_web
from birdie.models import Seat, SeatSnapshot, SeatState, Venue, VenueConfig, VenueSource, VenueType
from birdie.service import BirdieService
from birdie.storage import Repository


def test_public_api_reports_unknown_seats_as_not_free_and_hides_camera_polygons(clock):
    venue = Venue(
        venue_id="v", name="Venue", area="Doha", type=VenueType.cowork,
        lat=0, lon=0, zones=["open"], source=VenueSource.recorded,
    )
    seat = Seat(
        seat_id="A", venue_id="v", zone="open",
        polygon=[(0, 0), (1, 0), (1, 1)],
    )
    svc = BirdieService(
        repo=Repository(":memory:"), clock=clock,
        venues={"v": VenueConfig(venue=venue, seats=[seat])},
        detector=lambda image, threshold: [], persist_venues=False,
    )
    client = TestClient(create_app(svc))

    listed = client.get("/api/venues").json()[0]
    detail = client.get("/api/venues/v").json()
    assert listed["counts"]["free"] == 0
    assert listed["counts"]["unknown"] == 1
    assert listed["counts"]["taken"] == 0
    assert detail["seats"][0]["state"] == SeatState.UNKNOWN.value
    assert detail["seats"][0]["status"] == "checking"
    assert "polygon" not in detail["seats"][0]
    assert client.get("/api/venues/missing").status_code == 404


def test_public_availability_has_three_groups_and_a_checking_indicator():
    assert {state: _status(state) for state in SeatState} == {
        SeatState.UNKNOWN: "checking", SeatState.FREE: "free", SeatState.OCCUPIED: "taken",
        SeatState.HELD: "held", SeatState.REVIEW_REQUIRED: "held", SeatState.UNAVAILABLE: "taken",
    }
    now = datetime.fromisoformat("2026-11-07T12:00:00+00:00")
    states = [SeatState.FREE, SeatState.OCCUPIED, SeatState.UNAVAILABLE, SeatState.HELD,
              SeatState.REVIEW_REQUIRED, SeatState.UNKNOWN]
    snapshots = [SeatSnapshot(venue_id="v", seat_id=str(i), zone="open", has_outlet=False,
                              state=state, since=now, updated_at=now) for i, state in enumerate(states)]
    counts = _counts(snapshots)
    assert (counts["free"], counts["taken"], counts["held_or_review"], counts["checking"]) == (1, 2, 2, 1)
    assert counts["free"] + counts["taken"] + counts["held_or_review"] + counts["checking"] == counts["total"]


def test_mobile_web_is_served_after_api_routes(clock):
    venue = Venue(
        venue_id="v", name="Venue", area="Doha", type=VenueType.cowork,
        lat=0, lon=0, zones=["open"], source=VenueSource.recorded,
    )
    svc = BirdieService(
        repo=Repository(":memory:"), clock=clock,
        venues={"v": VenueConfig(venue=venue, seats=[])}, persist_venues=False,
        detector=lambda image, threshold: [],
    )
    app = create_app(svc)
    mount_mobile_web(app)
    client = TestClient(app)
    assert "Birdie" in client.get("/").text
    assert client.get("/api/venues").status_code == 200


def test_staff_override_requires_token_and_changes_availability(clock, monkeypatch):
    venue = Venue(
        venue_id="v", name="Venue", area="Doha", type=VenueType.cowork,
        lat=0, lon=0, zones=["open"], source=VenueSource.recorded,
    )
    seat = Seat(seat_id="A", venue_id="v", zone="open")
    svc = BirdieService(
        repo=Repository(":memory:"), clock=clock,
        venues={"v": VenueConfig(venue=venue, seats=[seat])},
        detector=lambda image, threshold: [], persist_venues=False,
    )
    client = TestClient(create_app(svc))
    monkeypatch.setenv("BIRDIE_ADMIN_TOKEN", "test-token")

    response = client.put("/api/venues/v/seats/A/unavailable", json={"unavailable": True})
    assert response.status_code == 401
    response = client.put(
        "/api/venues/v/seats/A/unavailable", json={"unavailable": True},
        headers={"Authorization": "Bearer test-token"},
    )
    assert response.status_code == 200
    assert response.json()["state"] == SeatState.UNAVAILABLE.value
    assert svc.counts("v")[SeatState.FREE] == 0


def test_forecast_starts_from_the_live_count_and_covers_three_hours(clock):
    venue = Venue(
        venue_id="v", name="Venue", area="Doha", type=VenueType.library,
        lat=0, lon=0, zones=["open"], source=VenueSource.simulated, drive_minutes=15,
    )
    seats = [Seat(seat_id=f"A{i}", venue_id="v", zone="open") for i in range(1, 9)]
    svc = BirdieService(
        repo=Repository(":memory:"), clock=clock,
        venues={"v": VenueConfig(venue=venue, seats=seats)},
        detector=lambda image, threshold: [], persist_venues=False,
    )
    client = TestClient(create_app(svc))

    listed = client.get("/api/venues").json()[0]["forecast"]
    detail = client.get("/api/venues/v").json()
    timeline = detail["forecast"]["timeline"]
    assert listed["arrival"]["minutes"] == 15
    assert "timeline" not in listed
    assert [p["minutes"] for p in timeline] == [0, 30, 60, 90, 120, 150, 180]
    assert timeline[0]["expected_free"] == detail["counts"]["usable_study_spaces"]
    assert all(0 <= p["low"] <= p["expected_free"] <= p["high"] <= 8 for p in timeline)