MIL-002 Flight data and indirect search #24

Merged
Tirsvad merged 2 commits from mil-002-indirect-search into main 2026-10-24 17:48:01 +02:00
18 changed files with 276 additions and 17 deletions
Showing only changes of commit 52afb369a7 - Show all commits
@@ -0,0 +1,55 @@
# Keep the assignment's flat module layout and console output
## Metadata
| Key | Value |
| --- | --- |
| ID | ADR-0001 |
| CrossReference | [BC-001], [SA-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending |
Allowed Status values: `Proposed`, `Accepted`, `Rejected`, `Deprecated`, `Superseded by ADR-NNNN`.
---
## Context
The Day 40 program is an assignment solution. Stakeholder S02 wants the assignment's file and class names (`main.py`, `data_manager.py`, `flight_search.py`, `flight_data.py`, `notification_manager.py`), and S03 wants a small repository ([SA-001] conflicts). The Python checklist (`QC-PY-001`) asks for the layer folders `domain`, `application`, `infrastructure` and `presentation`, for logging instead of `print`, and for a Design Class Diagram.
Options considered:
1. Full clean-architecture layout with a `logging` set-up and a DCD. Cleanest on paper, but it hides the assignment's names behind layers and adds about four packages for five small classes.
2. Keep the Day 39 flat package `src/flight_club/` with one module per assignment class, console output through `print`, no DCD. Small and recognisable; the layers are merged.
3. A middle way with `domain/` only. Splits the two data classes from the rest and still breaks the assignment's file names.
The forces: the program is a one-shot console script whose output *is* the user interface ("Print a message" in the assignment), has about 300 lines and five classes, and has one delivery mechanism.
## Decision
Keep option 2: one flat package with the assignment's module and class names. The modules still depend inward in practice (`constants` imports nothing and `flight_data` imports only `constants`; `main` is the composition root and the only module that wires them), console messages use `print` because they are the program's output, and the assignment's class list serves as the design instead of a separate DCD.
## Consequences
**Positive:**
- S02 finds the assignment's names; S03 sees a small repository with no extra packages.
- The Day 39 code and tests carry over without a restructuring.
**Negative:**
- The layers `domain`, `application`, `infrastructure` and `presentation` are merged; `QC-PY-001` criterion 17 is met only by this ADR.
- `print` is used for the program's output, so there is no log level or log file; secrets are kept out of the output by printing error types instead of error texts.
- If the program grows (a second delivery mechanism, a database), this decision must be revisited and the layers split.
## Affected Artifacts
- [BC-001] — keeps the Business Case scope of the assignment's classes and names.
- [SA-001] — resolves the conflict between S02 and S03 and the clean-architecture folders.
---
[BC-001]: ../business-case.md
[SA-001]: ../stakeholder-analysis.md
+2 -1
View File
@@ -15,7 +15,8 @@ document of a type. `Primary File` may contain a glob (e.g.
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone | docs/milestones/mil-*.md | 006 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 008 |
| ADR | Architecture Decision Record | docs/adr/adr-*.md | 0002 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 010 |
## Languages
+1 -1
View File
@@ -62,7 +62,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
---
[BC-001]: ../../docs/business-case.md
[BC-001]: ../../business-case.md
[QC-BC-001]: ../../../framework/qc/artifacts/qc-business-case.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -62,7 +62,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
---
[SA-001]: ../../docs/stakeholder-analysis.md
[SA-001]: ../../stakeholder-analysis.md
[QC-SA-001]: ../../../framework/qc/artifacts/qc-stakeholder-analysis.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
---
[MIL-001]: ../../docs/milestones/mil-001-baseline-and-setup.md
[MIL-001]: ../../milestones/mil-001-baseline-and-setup.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
---
[MIL-002]: ../../docs/milestones/mil-002-flight-data-and-indirect-search.md
[MIL-002]: ../../milestones/mil-002-flight-data-and-indirect-search.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
---
[MIL-003]: ../../docs/milestones/mil-003-customer-emails.md
[MIL-003]: ../../milestones/mil-003-customer-emails.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
---
[MIL-004]: ../../docs/milestones/mil-004-email-notifications-and-main.md
[MIL-004]: ../../milestones/mil-004-email-notifications-and-main.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
---
[MIL-005]: ../../docs/milestones/mil-005-documentation-and-wiki.md
[MIL-005]: ../../milestones/mil-005-documentation-and-wiki.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
+52
View File
@@ -0,0 +1,52 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-008 |
| CrossReference | [ADR-0001], [QC-ADR-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending |
---
## Artifact Under Review
- Instance reviewed: [ADR-0001]
- Checklist used: [QC-ADR-001]
- Scope: full review
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | The ID (`ADR-NNNN`, 4-digit) matches the filename number and is unique and sequential | Pass | ADR-0001 matches `adr-0001-...md`; it is the first ADR. |
| 2 | Context states the problem, the forces (cost, risk, constraints) and the options evaluated | Pass | Context lists the problem, the forces (assignment names, small program, console UI) and three options. |
| 3 | At least two options were considered, or the absence of alternatives is justified | Pass | Three options are evaluated. |
| 4 | Decision is stated in one or two unhedged sentences and matches one of the evaluated options | Pass | Decision is two sentences and matches option 2. |
| 5 | Consequences list both Positive and Negative outcomes | Pass | Consequences list Positive and Negative outcomes. |
| 6 | Quality-attribute impacts (performance, security, portability, etc.) are named where the decision affects them | Pass | Maintainability and extensibility impacts are named in the Negative consequences. |
| 7 | Affected Artifacts are listed as links, and each is also in CrossReference | Pass | Affected Artifacts [BC-001] and [SA-001] are linked and are also in CrossReference. |
| 8 | Status changes are recorded as new Version History rows with a Change summary and Commit link; retained rows are never rewritten | Pass | First version; one Version History row. |
| 9 | A superseded or deprecated ADR names its successor, and the successor links back | N-A | Optional: not superseded or deprecated. |
| 10 | Written in business or technical language of the domain, with no unexplained abbreviations | Pass | Plain technical English; abbreviations are common (ADR, DCD, UI). |
| 11 | Status is one of `Proposed`, `Accepted`, `Rejected`, `Deprecated` or `Superseded by ADR-NNNN`, and every Version History row uses only these | Pass | Status is Accepted, an allowed value. |
## Overall Verdict
Go — all mandatory criteria pass.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | - | - |
---
[ADR-0001]: ../../adr/adr-0001-flat-module-layout-and-console-output.md
[QC-ADR-001]: ../../../framework/qc/artifacts/qc-adr.md
+60
View File
@@ -0,0 +1,60 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-009 |
| CrossReference | [MIL-002], [QC-PY-001], [ADR-0001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending |
---
## Artifact Under Review
- Instance reviewed: [MIL-002] (the Python code and tests of its tasks 1 to 4: `src/flight_club/`, `tests/`)
- Checklist used: [QC-PY-001]
- Scope: full review of the code changed by this milestone
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, functions, variables and classes use snake_case, PascalCase and UPPER_SNAKE (`STOPS_NONSTOP`, `is_direct`). |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`is_direct`, `stops`, `indirect_price`); no cryptic abbreviations. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass; no new suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every new function and test has annotations including `-> None`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except and nothing swallowed; the new code adds no exception handling. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults (`is_direct: bool = True`, `stops: int = 0`); no shadowed builtins. |
| 7 | Files, locks and connections are managed with context managers | Pass | No files or connections opened by the new code; the HTTP session is unchanged. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public classes and functions carry Doxygen comments (what, not how). |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Console output uses `print` as the assignment requires; recorded in [ADR-0001]. No secrets are printed. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No DCD exists; the assignment's classes are the design; deviation recorded in [ADR-0001]. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | New behaviour is tested: stops parsing, request parameters of both searches, fallback and messages. Tests use fakes, no network, no order dependence. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes on `src` and `tests`; the only `Any` is the JSON response shape. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | No new dependencies. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The stops filter values are constants in one place; the fallback rule lives in `main.run` only. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `flight_data` imports only `constants`; `main` wires the others; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `check_flights` gains a defaulted parameter, so existing callers still work; `FlightData` gains a defaulted field. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Flat layout accepted by [ADR-0001] (merged layers). |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File names are lower-case snake_case English with no spaces. |
## Overall Verdict
Go — all mandatory criteria pass; deviations are recorded in ADR-0001.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | - | - |
---
[MIL-002]: ../../milestones/mil-002-flight-data-and-indirect-search.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[ADR-0001]: ../../adr/adr-0001-flat-module-layout-and-console-output.md
+6
View File
@@ -36,6 +36,12 @@ NOT_AVAILABLE = "N/A"
## @brief strftime format of dates sent to and read from providers.
DATE_FORMAT = "%Y-%m-%d"
## @brief SerpApi `stops` value that asks for nonstop flights only.
STOPS_NONSTOP = "1"
## @brief SerpApi `stops` value that allows up to two stops.
STOPS_UP_TO_TWO = "3"
## @brief Name of the SerpApi flight provider.
PROVIDER_SERPAPI = "serpapi"
+3 -1
View File
@@ -9,7 +9,7 @@ from typing import Any
from flight_club.constants import NOT_AVAILABLE
## @brief The cheapest flight found for one destination.
## @brief The cheapest flight found for one destination; `stops` is the number of stopovers.
@dataclass
class FlightData:
price: float | int | str
@@ -17,6 +17,7 @@ class FlightData:
destination_airport: str
out_date: str
return_date: str
stops: int = 0
def _unavailable() -> FlightData:
@@ -48,6 +49,7 @@ def _build_flight(entry: dict[str, Any], price: float, return_date: str) -> Flig
destination_airport=legs[-1]["arrival_airport"]["id"],
out_date=legs[0]["departure_airport"]["time"].split(" ")[0],
return_date=return_date,
stops=len(legs) - 1,
)
except (KeyError, IndexError, TypeError, AttributeError):
return None
+9 -1
View File
@@ -24,6 +24,8 @@ from flight_club.constants import (
REQUEST_TIMEOUT_SECONDS,
SERPAPI_ENGINE,
SERPAPI_URL,
STOPS_NONSTOP,
STOPS_UP_TO_TWO,
)
@@ -93,6 +95,7 @@ class FlightSearch:
destination_city_code: str,
from_time: datetime,
to_time: datetime,
is_direct: bool,
) -> dict[str, str]:
params = {
"engine": SERPAPI_ENGINE,
@@ -103,6 +106,7 @@ class FlightSearch:
"type": "1",
"adults": "1",
"currency": CURRENCY,
"stops": STOPS_NONSTOP if is_direct else STOPS_UP_TO_TWO,
}
if self._api_key is not None:
params["api_key"] = self._api_key
@@ -113,6 +117,7 @@ class FlightSearch:
# @param destination_city_code IATA code to arrive at.
# @param from_time Outbound date.
# @param to_time Return date.
# @param is_direct True for nonstop flights only, False to allow up to two stops.
# @return The decoded response, or None after printing why the search failed.
def check_flights(
self,
@@ -120,8 +125,11 @@ class FlightSearch:
destination_city_code: str,
from_time: datetime,
to_time: datetime,
is_direct: bool = True,
) -> dict[str, Any] | None:
params = self._build_params(origin_city_code, destination_city_code, from_time, to_time)
params = self._build_params(
origin_city_code, destination_city_code, from_time, to_time, is_direct
)
try:
response = self._get_session().get(
self._url,
+7
View File
@@ -55,10 +55,17 @@ def run(
if not _is_checkable(row):
print(f"Skipping row without an IATA code or a numeric lowest price: {row.get('city')}")
continue
print(f"Getting direct flights for {row['city']}...")
flights = flight_search.check_flights(
ORIGIN_AIRPORT, row["iataCode"], tomorrow, six_month_from_today
)
cheapest = find_cheapest_flight(flights, return_date)
if cheapest.price == NOT_AVAILABLE:
print(f"No direct flight to {row['city']}. Looking for indirect flights...")
flights = flight_search.check_flights(
ORIGIN_AIRPORT, row["iataCode"], tomorrow, six_month_from_today, is_direct=False
)
cheapest = find_cheapest_flight(flights, return_date)
print(f"{row['city']}: {CURRENCY} {cheapest.price}")
if cheapest.price != NOT_AVAILABLE and cheapest.price < row["lowestPrice"]:
print(f"Lower price flight found to {row['city']}!")
+18
View File
@@ -133,3 +133,21 @@ def test_find_cheapest_flight_trims_time_from_out_date() -> None:
result = find_cheapest_flight({"best_flights": [_entry(50)]}, RETURN_DATE)
assert result.out_date == "2026-07-01"
assert result.return_date == RETURN_DATE
def _multi_leg_entry(price: float, legs: int) -> dict[str, Any]:
segment = {
"departure_airport": {"id": "LHR", "time": "2026-07-01 08:00"},
"arrival_airport": {"id": "DPS", "time": "2026-07-02 10:00"},
}
return {"flights": [segment] * legs, "price": price}
@pytest.mark.parametrize(("legs", "stops"), [(1, 0), (2, 1), (3, 2)])
def test_stops_is_the_number_of_segments_minus_one(legs: int, stops: int) -> None:
cheapest = find_cheapest_flight({"best_flights": [_multi_leg_entry(500, legs)]}, RETURN_DATE)
assert cheapest.stops == stops
def test_unavailable_flight_has_no_stops() -> None:
assert find_cheapest_flight(None, RETURN_DATE).stops == 0
+13
View File
@@ -70,6 +70,7 @@ def test_check_flights_sends_serpapi_params() -> None:
"type": "1",
"adults": "1",
"currency": CURRENCY,
"stops": "1",
"api_key": API_KEY,
}
assert kwargs["timeout"] == REQUEST_TIMEOUT_SECONDS
@@ -217,3 +218,15 @@ def test_check_flights_never_prints_mock_secrets(
captured = capsys.readouterr()
assert APP_ID not in captured.out + captured.err
assert APP_KEY not in captured.out + captured.err
def test_check_flights_asks_for_up_to_two_stops_when_not_direct() -> None:
session = _session()
FlightSearch(session).check_flights("LHR", "DPS", FROM_TIME, TO_TIME, is_direct=False)
assert session.get.call_args.kwargs["params"]["stops"] == "3"
def test_check_flights_is_direct_by_default() -> None:
session = _session()
_search(session)
assert session.get.call_args.kwargs["params"]["stops"] == "1"
+44 -7
View File
@@ -24,21 +24,31 @@ class FakeDataManager:
class FakeFlightSearch:
def __init__(self, price: float | None) -> None:
def __init__(self, price: float | None, indirect_price: float | None = None) -> None:
self.price = price
self.indirect_price = indirect_price
self.calls: list[tuple[str, str, datetime, datetime]] = []
self.direct_flags: list[bool] = []
def check_flights(
self, origin: str, destination: str, from_time: datetime, to_time: datetime
self,
origin: str,
destination: str,
from_time: datetime,
to_time: datetime,
is_direct: bool = True,
) -> dict[str, Any] | None:
self.calls.append((origin, destination, from_time, to_time))
if self.price is None:
self.direct_flags.append(is_direct)
price = self.price if is_direct else self.indirect_price
if price is None:
return None
leg = {
"departure_airport": {"id": origin, "time": "2026-10-25 08:00"},
"arrival_airport": {"id": destination, "time": "2026-10-25 10:00"},
}
return {"best_flights": [{"price": self.price, "flights": [leg]}]}
legs = [leg] if is_direct else [leg, leg]
return {"best_flights": [{"price": price, "flights": legs}]}
class FakeNotificationManager:
@@ -54,11 +64,11 @@ class FakeNotificationManager:
def _run(
rows: list[dict[str, Any]], price: float | None
rows: list[dict[str, Any]], price: float | None, indirect_price: float | None = None
) -> tuple[list[FlightData], FakeDataManager, FakeFlightSearch, FakeNotificationManager]:
data, search, notifier = (
FakeDataManager(rows),
FakeFlightSearch(price),
FakeFlightSearch(price, indirect_price),
FakeNotificationManager(),
)
found = run(data, search, notifier, today=TODAY) # type: ignore[arg-type]
@@ -108,7 +118,7 @@ def test_run_skips_row_without_iata_code() -> None:
def test_run_checks_every_row_with_origin_and_dates() -> None:
rows = [{**_row(), "iataCode": code, "id": i} for i, code in enumerate(["CDG", "BER", "TYO"])]
_, _, search, _ = _run(rows, None)
_, _, search, _ = _run(rows, 54)
assert [c[:2] for c in search.calls] == [("LHR", "CDG"), ("LHR", "BER"), ("LHR", "TYO")]
for call in search.calls:
assert call[2] == TODAY + timedelta(days=1)
@@ -128,3 +138,30 @@ def test_main_exits_with_one_on_data_manager_error(
main_module.main()
assert exit_info.value.code == 1
assert "SHEETY_ENDPOINT" in capsys.readouterr().out
def test_run_prints_direct_search_message(capsys: pytest.CaptureFixture[str]) -> None:
_run([_row(100)], 54)
assert "Getting direct flights for Paris..." in capsys.readouterr().out
def test_run_searches_only_direct_when_a_direct_flight_exists() -> None:
_, _, search, _ = _run([_row(100)], 54, 10)
assert search.direct_flags == [True]
def test_run_falls_back_to_indirect_flights(capsys: pytest.CaptureFixture[str]) -> None:
found, data, search, notifier = _run([_row(100)], None, 70)
assert search.direct_flags == [True, False]
assert "No direct flight to Paris. Looking for indirect flights..." in capsys.readouterr().out
assert [f.price for f in found] == [70]
assert found[0].stops == 1
assert data.updates == [(2, 70)]
assert notifier.sent == ["alert CDG 70.0"]
def test_run_reports_nothing_when_neither_search_finds_a_flight() -> None:
found, data, search, _ = _run([_row(100)], None, None)
assert search.direct_flags == [True, False]
assert found == []
assert data.updates == []