trip: redesign itinerary display and add trip list macro

- Add render_trip_element macro to macros.html; use it in trip_item for
  the trip list page, giving a consistent one-line-per-element format
  with emoji, route, times, duration, operator, distance, and CO₂
- Redesign trip_page.html itinerary: day headers use date-only (no year),
  condense check-out to a single accent line, show time-only on transport
  cards, humanise duration (Xh Ym), km-only distance, add CO₂ for all
  transport modes, fix seat display for integer seat values
- Fix UndefinedError on /trip/past caused by absent 'arrive' key (Jinja2
  Undefined is truthy) and date-only depart/arrive fields (no .date())
- Improve mobile map layout: text column before map in HTML order, reduce
  mobile map heights, hide toggle button on mobile
- Add trips.css with design system (Playfair Display / Source Sans 3 /
  JetBrains Mono, navy/gold/amber palette, card variants by type)
- Add tests/test_trip_list_render.py covering the rendering edge cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-03-03 00:54:42 +00:00
parent b3975dad3a
commit 574b4feb1f
5 changed files with 935 additions and 338 deletions

View file

@ -0,0 +1,188 @@
"""Tests for trip list template rendering.
Covers edge cases found in real data that caused UndefinedError / AttributeError:
1. Flight where 'arrive' key is entirely absent (older data format).
Jinja2 returns Undefined for missing dict keys, which is truthy, so a
plain truthiness check on item.arrive would pass and then .date() would
raise UndefinedError.
2. Train where depart/arrive are date objects (not datetime).
datetime.date has no .date() method, so item.depart.date() raises
AttributeError (surfaced as UndefinedError inside Jinja2).
"""
from datetime import date, datetime, timezone
import flask
import pytest
import web_view
from agenda.types import TripElement
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeTrip:
"""Minimal trip stub sufficient for render_trip_element."""
show_flags = False
def _render(element: TripElement) -> str:
"""Render a single TripElement via the render_trip_element macro."""
with web_view.app.app_context():
with web_view.app.test_request_context():
flask.g.user = web_view.User(is_authenticated=False)
macros = web_view.app.jinja_env.get_template("macros.html")
return macros.module.render_trip_element(element, _FakeTrip())
def _flight(detail: dict) -> TripElement:
return TripElement(
start_time=detail["depart"],
title="test flight",
element_type="flight",
detail=detail,
start_loc="Origin",
end_loc="Dest",
)
def _train(detail: dict) -> TripElement:
return TripElement(
start_time=detail["depart"],
title="test train",
element_type="train",
detail=detail,
start_loc=detail["from"],
end_loc=detail["to"],
)
# ---------------------------------------------------------------------------
# Unit tests — specific edge cases
# ---------------------------------------------------------------------------
def test_flight_missing_arrive_key() -> None:
"""Flight with no 'arrive' key must not raise UndefinedError.
Older trips store flights without an arrival time. Jinja2 returns
Undefined (truthy!) for absent dict keys, so a bare truthiness check
was insufficient only 'is defined' correctly guards against it.
"""
element = _flight(
{
"depart": datetime(2023, 7, 1, 13, 20, tzinfo=timezone.utc),
# 'arrive' key intentionally absent
"from": "TIA",
"to": "LHR",
"flight_number": "BA123",
"airline_code": "BA",
"airline_name": "British Airways",
"distance": 2000.0,
}
)
result = _render(element)
assert "Origin" in result
assert "Dest" in result
assert "13:05" not in result # arrive time should not appear
def test_flight_arrive_is_none() -> None:
"""Flight where 'arrive' key exists but value is None must render cleanly."""
element = _flight(
{
"depart": datetime(2023, 7, 1, 13, 20, tzinfo=timezone.utc),
"arrive": None,
"from": "TIA",
"to": "LHR",
"flight_number": "BA123",
"airline_code": "BA",
"airline_name": "British Airways",
"distance": 2000.0,
}
)
result = _render(element)
assert "Origin" in result
def test_train_date_only_fields() -> None:
"""Train with date (not datetime) depart/arrive must not raise AttributeError.
Some older train entries record only the travel date with no time.
datetime.date has no .date() method, so item.depart.date() would fail.
"""
element = _train(
{
"depart": date(2024, 10, 22),
"arrive": date(2024, 10, 22),
"from": "Llandrindod",
"to": "Llandovery",
"operator": "Transport for Wales",
}
)
result = _render(element)
assert "Llandrindod" in result
assert "Llandovery" in result
def test_train_overnight_datetime() -> None:
"""Overnight train (arrive on next day) shows night moon emoji and +1 day."""
tz = timezone.utc
element = _train(
{
"depart": datetime(2026, 1, 19, 19, 6, tzinfo=tz),
"arrive": datetime(2026, 1, 20, 10, 13, tzinfo=tz),
"from": "Brussels Midi",
"to": "Vienna Hbf",
"operator": "ÖBB",
}
)
result = _render(element)
assert "🌙" in result
assert "+1 day" in result
def test_train_seat_as_integer() -> None:
"""Train with seat as an integer (not a list) must render without error."""
tz = timezone.utc
element = _train(
{
"depart": datetime(2025, 5, 26, 13, 5, tzinfo=tz),
"arrive": datetime(2025, 5, 26, 13, 29, tzinfo=tz),
"from": "Wánchaq",
"to": "Puno",
"coach": "C",
"seat": 1,
}
)
result = _render(element)
assert "Wánchaq" in result
# ---------------------------------------------------------------------------
# Integration tests — render the real trip list pages end-to-end
# ---------------------------------------------------------------------------
@pytest.fixture
def client():
"""Flask test client."""
web_view.app.config["TESTING"] = True
with web_view.app.test_client() as c:
yield c
def test_past_trips_page_renders(client) -> None:
"""Past trips list page renders without any template errors (HTTP 200)."""
response = client.get("/trip/past")
assert response.status_code == 200
def test_future_trips_page_renders(client) -> None:
"""Future trips list page renders without any template errors (HTTP 200)."""
response = client.get("/trip/future")
assert response.status_code == 200