diff --git a/CHANGELOG.md b/CHANGELOG.md index 834d0ec6..fb1c1784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,18 +9,132 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Upgrade note -If any LiveLink device reports a custom odometer PID (a bare `ODOMETER` autopid rather than the standard `A6-ODOMETER`), its stored odometer telemetry and drive-session odometers are in miles and this release starts reading them as kilometres. Migration 096 classifies the devices but deliberately converts no data: an instance that has already converted by hand cannot be told apart from one that has not, and converting twice is unrecoverable. Back up first, then run, in this order: +**Back up first, and read this in full.** This release cannot be downgraded. + +#### Before upgrading + +Take a full backup: `POST /api/backup/create-full`, or Settings -> Backup -> +Create Full Backup. On SQLite this uses the SQLite Online Backup API; MyGarage +runs in WAL mode, so a plain `cp` of the database file produces a copy that is +torn but plausible. On PostgreSQL, use `pg_dump`. + +The backup is the only way back. There are no down-migrations, and once this +release's migrations have run, an older image cannot read the database. + +#### Odometer units on custom PIDs + +If any LiveLink device reports a custom odometer PID (a bare `ODOMETER` autopid +rather than the standard `A6-ODOMETER`), its stored odometer telemetry and +drive-session odometers are in miles and this release starts reading them as +kilometres. Migration 096 classifies the devices but deliberately converts no +data: an instance that has already converted by hand cannot be told apart from +one that has not, and converting twice is unrecoverable. + +The repair tools must see the data exactly as the migration left it, before any +new reading arrives. Start the container in maintenance mode to get that window: +migrations run, but no telemetry can be written by any path: the scheduler and +MQTT subscriber do not start and cannot be restarted, the two ingest endpoints +and the admin SD-card backfill answer 503, and the three telemetry writers +themselves refuse. So neither a dongle replaying its buffer, nor a manual +backfill, nor an MQTT restart can land readings mid-repair. The rest of the +admin API stays open, so you can watch the repair and turn maintenance mode back +off. ``` -python tools/backfill_livelink_odometer.py --db /data/mygarage.db --apply -python tools/normalize_telemetry_odometer_units.py --db /data/mygarage.db --apply -python tools/fix_session_odometer_units.py --db /data/mygarage.db --apply -python tools/recompute_session_aggregates.py --apply -``` +# 1. start in maintenance mode (compose: add to the service's environment) +MYGARAGE_MAINTENANCE_MODE=1 + +# 2. run the repair, in this order +docker exec -w /app mygarage python tools/backfill_livelink_odometer.py --apply +docker exec -w /app mygarage python tools/normalize_telemetry_odometer_units.py --apply +docker exec -w /app mygarage python tools/fix_session_odometer_units.py --apply +docker exec -w /app mygarage python tools/recompute_session_aggregates.py --apply -The order matters. The reconstruction reads telemetry as the device reported it and applies the device's declared unit, so it has to run while the history is still device-native; after the conversion it would multiply an already-metric figure again, and an inflated odometer record becomes the floor every later reading must beat rather than something a later reading corrects. +# 3. remove MYGARAGE_MAINTENANCE_MODE and restart +``` -Run them before the upgraded instance records new readings. Each is dry run by default and reads from the data whether its work is still outstanding, so running one twice cannot double a value. Where that cannot be decided safely, the tool says so and exits 2 rather than guess: the reconstruction refuses telemetry that already reads as metric, and the two converters refuse a device whose history mixes miles and kilometres, which is what a run started after new readings landed leaves behind. +The tools default to the instance's own configured database, so `--db` is only +needed to point one somewhere else; it accepts a path or a full SQLAlchemy URL. +Both SQLite and PostgreSQL are supported, and the sequence is exercised +end-to-end against both in CI. + +The order matters. The odometer backfill reads telemetry as the device reported +it and applies the device's declared unit, so it has to run while the history is +still device-native; after the conversion it would multiply an already-metric +figure again, and an inflated odometer record becomes the floor every later +reading must beat rather than something a later reading corrects. + +Each tool is dry run by default and reads from the data whether its work is +still outstanding, so running one twice cannot double a value. Where that cannot +be decided safely, the tool says so and **exits 2** rather than guess: the +reconstruction refuses telemetry that already reads as metric, and the two +converters refuse a device whose history mixes miles and kilometres, which is +what a run started after new readings landed leaves behind. A dry run that +refused something also exits 2, so a script may gate `--apply` on it. + +**`recompute_session_aggregates.py` will lower the distance on old drives, often +sharply.** A session's distance used to be the difference between the vehicle's +newest odometer reading when it closed and when it opened, so every kilometre +driven while no session was open was charged to whichever session opened next. +That tool recomputes each session from the telemetry inside its own window, and +a session that was mostly a parked vehicle checking in contains very little. On +the instance this was developed against, one vehicle's recorded session distance +went from 3,890 km to 340 km. + +Of that 340 km, 104 km was recomputed from telemetry still on disk and 236 km is +older figures left untouched on 18 sessions whose telemetry has since been +pruned. Nothing blanks a session it cannot recompute, because for a drive past +the retention horizon that stored figure is the only record left of it. So the +total after this step is a mix of the two, and it keeps shrinking as more +history ages out. + +Your vehicle's mileage is not affected. Odometer records are a separate table +fed by its own readings, and nothing here writes to it. Only the distance shown +against individual LiveLink drives changes. + +**How far back the repair reaches.** All four tools work from telemetry still on +disk, and telemetry is pruned to `livelink_telemetry_retention_days` (default +90). Readings older than that are gone and cannot be reconstructed. If you want +more history repaired, raise that setting and wait for the data to age out more +slowly *before* upgrading; the nightly prune runs at 04:00. + +#### Drives are counted differently from this release on + +A drive session used to start whenever the dongle could reach the broker. A +parked WiCAN checks in roughly every 95 minutes, so most recorded "drives" were +a parked vehicle: on the instance this was found on, 2,975 of 3,238. Drives +taken out of broker range were missed instead. + +Sessions are now decided by movement, so from this release your drive count +drops sharply and the remaining drives are real ones. + +Existing history is left exactly as it was: nothing is deleted, merged or +rewritten. Every session records which rule produced it, so drives recorded +before this release stay marked as such and a later release can revisit them. +Old drives keep their boundaries even if you run the repair sequence above, +which only recomputes the figures inside a window it does not move. + +The drive list does hide the sessions in which nothing moved, which is most of +them, and names the count so the page never just looks empty. A real journey +recorded by the old rule still shows, tagged so its figures can be read with +the right expectations. + +If your device reports no speed or odometer that MyGarage recognises, it will +record no drives at all. LiveLink settings names the device, and the container +log lists the readings it does send; set **How drives are detected** to "By +device connection" to keep the old behaviour for it. + +#### Tire wear estimates go quiet until you record a mount odometer + +Migration 097 gives every existing tire an assumed mount period whose starting +odometer is unknown, because nothing recorded one before now. Until you supply +it, the tire card says which reading is missing instead of showing a figure. + +If you have only ever run one set, the old estimate was correct for you and you +lose it until you enter that number. It is withheld rather than relabelled +because for anyone running a second set it was wrong by the distance driven on +the other set, and wrong in the direction that says a worn tire is fine. One +odometer per tire, on its mount, restores the estimate. ### Added - Structured vehicle maintenance specs (oil viscosity/capacity/filter, lug-nut torque, coolant/brake/transmission fluid) with an Overview editor (migration 095). @@ -28,8 +142,42 @@ Run them before the upgraded instance records new readings. Each is dry run by d - LiveLink devices carry an odometer unit, inferred from the PID shape and editable per device in LiveLink settings (migration 096). A standard `A6-ODOMETER` PID is kilometres per SAE J1979; a custom autopid is whatever the dash shows, and the instance can now say so. - `backend/tools/backfill_livelink_odometer.py` reconstructs the odometer records the units regression below discarded, from raw telemetry already on disk. Dry run by default, and it never overwrites a day that already has a record. - German translation updated across all six namespaces; thanks [@SCDT95](https://github.com/SCDT95) (#155). +- Tires record mount periods, so a tire's distance is summed over the times it was actually on the vehicle rather than taken from the odometer (migration 097, #153). +- Retire a tire instead of deleting it. Retiring keeps every reading and mount period; delete is still there for a tire entered by mistake (#153). +- Rotate all four tires in one action, choosing from the four standard patterns (#153). +- Tire sets: name a group such as "Winter studded" and fit it in one action, each tire returning to the corner it was last on (#153). +- Tires can be entered straight into storage, so a set you own but have not fitted is tracked like any other (#153). +- The tire card shows distance on tire, and says which reading is missing when it cannot work one out (#153). +- The drive list hides sessions in which the vehicle never moved, and says how many it is holding back. The old rule opened a drive whenever the device connected, so a parked vehicle checking in became one: on the instance this was developed against that is 2,921 of 3,262 recorded sessions. Nothing is deleted and one click shows them. The filter is movement, not the rule that recorded the session, because 341 of those same older sessions are real journeys. +- Vehicles record a fuel filter part number alongside the oil filter (migration 099). Available to every vehicle rather than diesels only: a diesel's filters are a scheduled item, and an older petrol vehicle's inline filter is a real service part too. A vehicle without one leaves it blank and the card omits the row. +- Engine oil capacity is read in quarts wherever fuel is read in gallons. It shared the fuel unit, so a reader on US gallons was asked for gallons of engine oil: entering `12` for a 12-quart engine stored 45.4 litres and the card read `12 gal` straight back, which made the error invisible from the screen. A reader on litres is unaffected, and a UK reader gets the Imperial quart. +- Drive distance is read from the finest distance signal a device publishes, not from the odometer alone. A WiCAN reporting its odometer only every 24 km recorded no distance for any trip shorter than that, which was most of them; the standard `31-DISTANCESINCECODECLEAR` PID alongside it resolves to 1 km. A device whose odometer already resolves at least as finely is unchanged, and a distance counter never supplies a session's odometer readings. +- Two LiveLink settings: **Stop before a new drive** (default 15 minutes) sets how long a stop lasts before the next movement counts as a separate drive, and **How drives are detected** switches between movement and the old connection-based rule. +- Analytics has a Tires section: tread over time, projected life, distance on tire, and a readiness block naming the one reading to record next (#152). It is on the Analytics page only for now; the PDF report and the garage export do not include it. + +### Changed +- Drive sessions are detected by movement rather than by the dongle connecting. A parked vehicle checking in no longer records a drive; see the upgrade note. +- **BREAKING (API):** `POST /api/vehicles/{vin}/tires` no longer accepts `position` and creates a stored tire. Mount it afterwards, or use `POST /api/vehicles/{vin}/tires/create-and-mount`, which does both atomically. A payload carrying `position` is rejected with HTTP 422 naming the field. +- Settings -> Integrations is laid out on the same cards as the rest of the app. It was still on pre-v3.0.0 markup, so seven sections hand-rolled their own headers, and a fixed two-column grid paired the tall NHTSA card against two short ones and left a quarter of that row empty. The sections now flow, and each one's description and toggle text sit in the same place. +- Integrations follows the Title Case rule: names of things are Title Case (LLM Features, Telegram Fuel Bot, Webhook Ingest Token, API Base URL) and toggle labels say what they do in sentence case. Both spellings were previously on the same screen. ### Fixed +- Drives taken out of range of the broker are recorded. Readings pulled from the dongle's SD card could update an existing session but never create one, so a drive away from home was recorded as nothing at all. +- A stop no longer splits one drive in two. The five-minute setting detects a lost connection and was also being used to end drives, so any stop longer than that became two trips: a charge, a fuel stop, or a school pickup. +- A drive ends when the vehicle stops moving, not when the dongle stops talking. Sessions were being closed at the last check-in, padding every drive with up to 95 minutes of parked readings and dragging its average speed down. +- Ending a drive no longer marks the ECU offline, which had been blocking remote commands after every trip. +- A drive session is closed when the ECU reports offline. The check looked for a change of state that had already been recorded, so it never fired and the session was left to time out instead. +- Two ingest paths arriving at once can no longer create a second, orphaned drive session that nothing ever closes. +- Warranty, insurance and tax CSV work in both directions. Export returned HTTP 500 for any vehicle with a warranty or an insurance record, and import rejected every warranty, insurance and tax row with "Invalid record data", blaming your file for an application bug. No tax record had ever imported successfully. +- Warranty CSV exports the mileage limit, in your own units. The column was missing entirely. +- Reminder notifications work on PostgreSQL. The notification timestamp was written with a timezone into a column that has none, so the write failed and no reminder notification had ever been sent on a PostgreSQL instance. SQLite accepted it, which is why it went unnoticed. +- Saving a service visit no longer does nothing without saying why. A value the browser rejected, such as a negative cost, a third decimal place or a blank date, aborted the save with no message, and where the field was inside a collapsed line item there was nothing on screen to look at. +- The tire wear estimate no longer over-states remaining life for anyone running two sets. It measured the whole odometer span between two readings, which counts the distance driven on the other set (#153). +- The odometer you type into a mount, dismount, rotation, retirement or tread reading is recorded as an odometer reading, so a tire's distance completes without entering the same number twice. +- The maintenance tools are now in the runtime image. `backend/tools/` was built and then discarded, so every command in the upgrade note above failed with `can't open file`. +- The maintenance tools work on PostgreSQL. Three of them hardcoded a SQLite path, which on PostgreSQL created an empty SQLite file and then failed with `no such table`, leaving those instances with no repair path. +- `MYGARAGE_MAINTENANCE_MODE=1` starts the instance for migrations only: no scheduler, no MQTT subscriber, and telemetry ingest answers 503. The upgrade note asks operators to repair data before new readings land, and there was previously no window in which that was possible. +- A dry run of `normalize_telemetry_odometer_units.py` that refused a mixed-unit device now exits 2 like its sibling tool, instead of reporting success. - Drive-session speed, RPM and temperature figures now count readings that arrive after the session has closed. A WiCAN buffers readings while it cannot reach the broker and replays them later with their original timestamps, and a session was summarised once on close, so a drive that peaked at 85 km/h could be recorded as 20. `backend/tools/recompute_session_aggregates.py` repairs sessions summarised before the fix. - Drive-session distance measures odometer movement inside the session. It was the difference between the vehicle's newest reading at each end, whatever their age, so every kilometre driven while no session was open was charged to whichever session opened next: a vehicle idling in a driveway for eleven minutes at a top speed of 2 km/h was credited with 14 km. Sessions that recorded distance the vehicle did not cover in that window are repaired by the same tool. - Readings pulled from a dongle's SD card now update the drive sessions they fall inside, and their odometer values are converted to kilometres. That path bypasses live ingest by design, because a pull is tens of thousands of rows, so it had been bypassing both. It is the only path for anything driven out of range of the broker. diff --git a/Dockerfile b/Dockerfile index 2ca0cc4f..61c8615e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,6 +95,12 @@ COPY --from=backend-builder /usr/local/bin /usr/local/bin COPY --from=backend-builder /app/app ./app COPY --from=backend-builder /app/pyproject.toml ./pyproject.toml +# Copy the maintenance tools. The upgrade notes in CHANGELOG.md tell operators +# to run these against a live instance, and without this they are not in the +# image at all: every documented command failed with "can't open file". +# They import `app.*` and expect to run from this directory. +COPY --from=backend-builder /app/tools ./tools + # Copy frontend build COPY --from=frontend-builder /app/frontend/dist ./static diff --git a/backend/app/config.py b/backend/app/config.py index fda77d5f..67ec6b09 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -95,6 +95,13 @@ def _normalize_root_path(cls, v: object) -> str: # Database database_url: str = "sqlite+aiosqlite:////data/mygarage.db" + # Maintenance mode. Starts the app far enough to apply migrations and no + # further: no scheduler, no MQTT subscriber, and telemetry ingest answers + # 503. It exists because the odometer repair tools must run before any new + # reading lands, and migrations run inside this app's own lifespan, so + # without it there is no window in which that instruction can be obeyed. + maintenance_mode: bool = False + # File Storage data_dir: Path = Path("/data") attachments_dir: Path = Path("/data/attachments") diff --git a/backend/app/database.py b/backend/app/database.py index f9ca81a4..bc0b3f13 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -139,13 +139,11 @@ async def init_db(): # Run migrations using the migration runner logger.info("Running database migrations...") try: - # Convert async database URL to sync for migrations - # asyncpg -> psycopg2, aiosqlite -> sqlite - sync_url = settings.database_url - if "asyncpg" in sync_url: - sync_url = sync_url.replace("postgresql+asyncpg", "postgresql+psycopg2") - elif "aiosqlite" in sync_url: - sync_url = sync_url.replace("sqlite+aiosqlite", "sqlite") + # Convert async database URL to sync for migrations. Shared with the + # maintenance tools under backend/tools/, which need the same mapping. + from app.utils.db_url import to_sync_url + + sync_url = to_sync_url(settings.database_url) # Import and run migration runner from app.migrations.runner import run_migrations diff --git a/backend/app/main.py b/backend/app/main.py index 0d09ac2e..163cfbc5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -163,12 +163,25 @@ async def lifespan(app: FastAPI): logger.warning("=" * 80) # Start scheduled background tasks (session timeouts, device offline detection, etc.) + from app.tasks.livelink_tasks import start_mqtt_subscriber, stop_mqtt_subscriber from app.tasks.scheduled import start_scheduler, stop_scheduler - start_scheduler() + if settings.maintenance_mode: + # Migrations have run; stop here. Neither the scheduler nor the MQTT + # subscriber may write, because the whole point of the window is that + # the repair tools see the data exactly as the migration left it. A + # buffered dongle replays its backlog within seconds of the broker + # connecting, which is precisely what this prevents. + logger.warning("=" * 80) + logger.warning("MAINTENANCE MODE: scheduler and MQTT subscriber not started") + logger.warning("MAINTENANCE MODE: telemetry ingest will answer 503") + logger.warning("MAINTENANCE MODE: unset MYGARAGE_MAINTENANCE_MODE and restart when done") + logger.warning("=" * 80) + yield + logger.info("Shutting down MyGarage application (maintenance mode)...") + return - # Start MQTT subscriber if enabled - from app.tasks.livelink_tasks import start_mqtt_subscriber, stop_mqtt_subscriber + start_scheduler() await start_mqtt_subscriber() @@ -215,6 +228,7 @@ async def lifespan(app: FastAPI): from app.middleware import ( CSRFProtectionMiddleware, IngestBodySizeLimitMiddleware, + MaintenanceModeMiddleware, RequestIDMiddleware, SecurityHeadersMiddleware, ) @@ -222,6 +236,9 @@ async def lifespan(app: FastAPI): # Innermost: the ingest body-size guard runs closest to the app, so its 413 # still flows out through RequestID + SecurityHeaders and is fully decorated. app.add_middleware(IngestBodySizeLimitMiddleware) +# Just outside it: in maintenance mode there is no point measuring a body we +# are about to refuse. +app.add_middleware(MaintenanceModeMiddleware) app.add_middleware(CSRFProtectionMiddleware) app.add_middleware(RequestIDMiddleware) app.add_middleware(SecurityHeadersMiddleware) diff --git a/backend/app/middleware.py b/backend/app/middleware.py index fc25bc65..b5908850 100644 --- a/backend/app/middleware.py +++ b/backend/app/middleware.py @@ -13,6 +13,7 @@ import json import logging import os +import re import uuid from collections.abc import Awaitable, Callable, Mapping @@ -238,6 +239,35 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: #: the (linear-time) normalizer. An optional Traefik `maxRequestBodyBytes` cap is #: documented as deploy-side defense-in-depth but is not in this repo (R1-H3). INGEST_PATH = "/api/v1/livelink/ingest" + +#: Router prefixes whose handlers write telemetry. Maintenance mode closes these +#: and nothing else: the rest of the API stays reachable so an operator can watch +#: the upgrade, and /api/health keeps answering so the container healthcheck does +#: not fail the maintenance window and trigger a restart. +#: Ingest routers, closed wholesale: everything under them writes telemetry. +MAINTENANCE_CLOSED_PREFIXES = ("/api/v1/livelink", "/api/v1/torque") + +#: Individual admin routes that write telemetry. The admin router as a whole +#: must stay OPEN -- the operator uses it to watch the repair and to turn +#: maintenance mode back off -- so these are matched exactly rather than by +#: prefix. `POST /api/livelink/devices/{id}/backfill` reaches `bulk_backfill`. +_MAINTENANCE_CLOSED_ADMIN_RE = re.compile(r"^/api/livelink/devices/[^/]+/backfill/?$") + + +def is_maintenance_closed(path: str) -> bool: + """Whether maintenance mode should refuse a request to ``path``. + + Args: + path: The request path, without query string. + + Returns: + True if the path can write telemetry and must be refused with 503. + """ + if path.startswith(MAINTENANCE_CLOSED_PREFIXES): + return True + return _MAINTENANCE_CLOSED_ADMIN_RE.match(path) is not None + + INGEST_MAX_BODY_BYTES = 256 * 1024 @@ -331,6 +361,48 @@ def _get_header(scope: Scope, name: bytes) -> str | None: return None +class MaintenanceModeMiddleware: + """Refuse telemetry ingest with 503 while the instance is in maintenance mode. + + The upgrade procedure for the odometer repair tools requires that no new + reading lands between the migration and the repair. Migrations run inside + this app's own lifespan and the MQTT toggle is a database row, so stopping + ingest from outside the process is not possible; this closes it from inside. + + ``settings.maintenance_mode`` is read per request rather than captured at + construction, so the gate can be flipped in tests without rebuilding the app. + The cost is one attribute read per request on a boolean that is almost always + False. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + from app.config import settings + + if scope["type"] != "http" or not settings.maintenance_mode: + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + if not is_maintenance_closed(path): + await self.app(scope, receive, send) + return + + logger.info("Maintenance mode: refused ingest request to %s", path) + await _send_json( + send, + status=503, + payload={ + "detail": ( + "MyGarage is in maintenance mode and is not accepting telemetry. " + "Readings buffered on the device will be delivered once it exits." + ) + }, + ) + + async def _send_json(send: Send, *, status: int, payload: Mapping[str, object]) -> None: """Emit a JSON response from inside ASGI middleware without recursing. diff --git a/backend/app/migrations/097_tire_mount_periods.py b/backend/app/migrations/097_tire_mount_periods.py new file mode 100644 index 00000000..6759caf3 --- /dev/null +++ b/backend/app/migrations/097_tire_mount_periods.py @@ -0,0 +1,701 @@ +"""Give tires a mount history, and make `position` mean "where it is now". + +Before this migration a tire row WAS a corner: `position` was NOT NULL and +`uq_tires_vin_position` meant one row per corner per vehicle. A tire taken off +the vehicle had nowhere to be, so a seasonal set had to be deleted and +re-entered every spring and autumn, losing its readings with it. + +After it, `tires.position` is nullable ("in storage" is a state), and +`tire_mount_periods` records where each tire has been and when. Distance and +wear are computed from those periods, not from the vehicle's raw odometer +delta -- which is the actual defect this exists to fix: `_project_wear` treated +the whole odometer span between two readings as distance driven ON THAT TIRE, +so anyone running two seasonal sets was told their tires had hundreds of +thousands of kilometres of life left. Erring high, on a tire. + +FATAL, because every later query assumes the new shape. + +WHY ONE TRANSACTION +------------------- +Steps 1-7 run inside a single transaction on both dialects. That is not +tidiness: the runner executes and stamps in SEPARATE transactions +(`runner.py:226`), so a crash between two non-transactional steps would leave a +schema that matches neither branch of the re-entrancy check. With one +transaction the observable states collapse to two, keyed on the LAST thing this +migration writes (`vehicle_reminders.source`) rather than the first. + +WHY THE PRAGMA IS READ BACK +--------------------------- +`PRAGMA foreign_keys = OFF` is a **no-op inside a transaction**, and SQLite +reports no error: the pragma read still returns 1. Measured. A rebuild that +assumes it worked will fire `ON DELETE CASCADE` on `DROP TABLE tires` and take +every `tire_readings` row with it, silently. This follows migration 070 +(`070:119-162`), which disables FKs on a raw DB-API connection outside any +transaction and asserts the read-back. Do NOT copy 092: it sets the pragma and +never checks, and survives only because its table has no children. + +Back up before deploying, with the backup API (`POST /api/backup/create-full`), +not `cp`: MyGarage runs in WAL mode and a plain file copy of a database with a +live WAL sidecar is torn but plausible. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from sqlalchemy import create_engine, inspect, text + +FATAL = True + +#: Every CHECK that step 7 restores on `vehicle_reminders`, with the scan that +#: proves the data can take it. Preflight P2 runs all of them: a violated CHECK +#: is skipped and logged, because a missing constraint is recoverable and a +#: crash-looping instance is not. +#: +#: Enumerated from the DDL step 7 writes, not from the ones that came to mind. +#: An earlier draft listed only the two vocabulary CHECKs and missed the range +#: CHECK -- which is the one with a reachable violation, via the legacy JSON +#: importer's `bool(is_recurring and recurrence_miles)` accepting a negative. +REMINDER_CHECKS: tuple[tuple[str, str, str], ...] = ( + ( + "check_reminder_type", + "reminder_type IN ('date','mileage','both','smart','hours')", + "SELECT id FROM vehicle_reminders WHERE reminder_type NOT IN " + "('date','mileage','both','smart','hours')", + ), + ( + "check_reminder_status", + "status IN ('pending','done','dismissed')", + "SELECT id FROM vehicle_reminders WHERE status NOT IN ('pending','done','dismissed')", + ), + ( + "check_due_mileage_km", + "due_mileage_km IS NULL OR due_mileage_km > 0", + "SELECT id FROM vehicle_reminders WHERE due_mileage_km IS NOT NULL AND due_mileage_km <= 0", + ), +) + + +def _get_fallback_engine(): + db_path = os.environ.get("DATABASE_PATH") + if db_path: + return create_engine(f"sqlite:///{db_path}") + data_dir = Path(os.getenv("DATA_DIR", "/data")) + return create_engine(f"sqlite:///{data_dir / 'mygarage.db'}") + + +# ============================================================================ +# Preflight +# ============================================================================ + + +def _preflight_vin_mismatches(cur) -> None: + """P1. Repair children whose `vin` disagrees with their tire's. + + The composite FK `(tire_id, vin) -> tires(id, vin)` exists to make that + disagreement impossible. Installing it while such a row exists fails the + rebuild, so the rows are repaired first rather than the migration dying on + data it could have fixed. The tire is the authority; the denormalised `vin` + on the child is a copy. + """ + for table in ("tire_readings", "vehicle_reminders"): + cols = {r[1] for r in cur.execute(f"PRAGMA table_info({table})").fetchall()} + if "tire_id" not in cols: + continue + bad = cur.execute( + f"SELECT c.id FROM {table} c JOIN tires t ON t.id = c.tire_id WHERE c.vin <> t.vin" + ).fetchall() + if not bad: + continue + print( + f" → P1: repairing {len(bad)} {table} row(s) with a mismatched vin: " + f"{[r[0] for r in bad]}" + ) + cur.execute( + f"UPDATE {table} SET vin = (SELECT vin FROM tires WHERE id = {table}.tire_id) " + f"WHERE id IN (SELECT c.id FROM {table} c JOIN tires t ON t.id = c.tire_id " + f"WHERE c.vin <> t.vin)" + ) + + +def _preflight_reminder_checks(cur) -> list[tuple[str, str]]: + """P2. Which CHECKs the data can actually take. + + Returns the (name, expression) pairs to include in the rebuilt table. A + CHECK whose data is violating is LOGGED AND SKIPPED, not enforced: adding + it would abort a FATAL migration and crash-loop the instance on every + restart until someone with database access repaired the row by hand. + """ + keep: list[tuple[str, str]] = [] + for name, expression, scan in REMINDER_CHECKS: + violations = [r[0] for r in cur.execute(scan).fetchall()] + if violations: + print( + f" → P2: SKIPPING {name}; {len(violations)} row(s) violate it: " + f"{violations}. The constraint is not installed. Repair those rows " + f"and a later release will add it." + ) + continue + keep.append((name, expression)) + return keep + + +def _preflight_backup_marker(engine) -> None: + """P3. Warn loudly if there is no recent full backup. + + This release cannot be downgraded: step 3 drops `tires.installed_date`, and + v3.2.0's ORM declares it, so an older image raises `no such column` on + every tire request. Restore is the only way back. + """ + data_dir = Path(os.getenv("DATA_DIR", "/data")) + backups = data_dir / "backups" + if not backups.is_dir() or not any(backups.iterdir()): + print( + " → P3: WARNING — no backup found in " + f"{backups}. This migration cannot be reversed: it drops " + "tires.installed_date, which the previous release's ORM requires. " + "If this upgrade goes wrong, restoring a backup is the only way " + "back. Take one now (POST /api/backup/create-full) if you have not." + ) + + +# ============================================================================ +# SQLite rebuilds +# ============================================================================ + + +def _rebuild_tires(cur) -> None: + """Step 3. `position` nullable, `set_id` + `retired_on` added, + `installed_date` dropped, `UNIQUE (id, vin)` added. + + `uq_tires_vin_position` is reproduced verbatim and NOT replaced by a + partial index: NULLs compare as distinct under UNIQUE on both dialects, so + once `position` is nullable the same constraint permits any number of + stored tires while still rejecting a second mounted tire at one corner. + + `tires` carries exactly one constraint and no CHECKs. Verified against a + production database rather than assumed -- an earlier draft of the design + said "reproduce all three CHECKs", carrying the count over from + `vehicle_reminders`, where the three actually live. + """ + cur.execute(""" + CREATE TABLE tires_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vin VARCHAR(17) NOT NULL, + position VARCHAR(10), + brand VARCHAR(80), + model_name VARCHAR(80), + size VARCHAR(40), + dot_code VARCHAR(20), + set_id INTEGER, + retired_on DATE, + tread_depth_mm NUMERIC(5, 2), + pressure_kpa NUMERIC(7, 2), + min_tread_mm NUMERIC(5, 2), + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT uq_tires_vin_position UNIQUE (vin, position), + CONSTRAINT uq_tires_id_vin UNIQUE (id, vin), + FOREIGN KEY (vin) REFERENCES vehicles(vin) ON DELETE CASCADE, + FOREIGN KEY (set_id) REFERENCES tire_sets(id) ON DELETE SET NULL + ) + """) + cur.execute(""" + INSERT INTO tires_new + (id, vin, position, brand, model_name, size, dot_code, + tread_depth_mm, pressure_kpa, min_tread_mm, notes, + created_at, updated_at) + SELECT id, vin, position, brand, model_name, size, dot_code, + tread_depth_mm, pressure_kpa, min_tread_mm, notes, + created_at, updated_at + FROM tires + """) + cur.execute("DROP TABLE tires") + cur.execute("ALTER TABLE tires_new RENAME TO tires") + cur.execute("CREATE INDEX idx_tires_vin ON tires (vin)") + + +def _rebuild_tire_readings(cur) -> None: + """Step 6. Add `mount_period_id`, drop `position`'s NOT NULL, move to the + composite `(tire_id, vin) -> tires(id, vin)`. + + Reproduces what migration **094** left, not what 085 wrote: 094 made + `tread_depth_mm` nullable so a reader with no tread gauge can log pressure + alone, and transcribing 085's column list would silently undo it. + + The composite FK is `ON DELETE CASCADE`, unlike the reminder one. A tread + reading is OWNED by its tire and is meaningless without it, whereas a + reminder is history about a vehicle that happens to name a tire. + """ + cur.execute(""" + CREATE TABLE tire_readings_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tire_id INTEGER NOT NULL, + vin VARCHAR(17) NOT NULL, + position VARCHAR(10), + mount_period_id INTEGER, + recorded_at DATE NOT NULL, + odometer_km NUMERIC(10, 2), + tread_depth_mm NUMERIC(5, 2), + pressure_kpa NUMERIC(7, 2), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (tire_id, vin) REFERENCES tires(id, vin) ON DELETE CASCADE, + FOREIGN KEY (vin) REFERENCES vehicles(vin) ON DELETE CASCADE, + FOREIGN KEY (mount_period_id) REFERENCES tire_mount_periods(id) + ON DELETE SET NULL + ) + """) + cur.execute(""" + INSERT INTO tire_readings_new + (id, tire_id, vin, position, recorded_at, odometer_km, + tread_depth_mm, pressure_kpa, notes, created_at) + SELECT id, tire_id, vin, position, recorded_at, odometer_km, + tread_depth_mm, pressure_kpa, notes, created_at + FROM tire_readings + """) + cur.execute("DROP TABLE tire_readings") + cur.execute("ALTER TABLE tire_readings_new RENAME TO tire_readings") + cur.execute("CREATE INDEX idx_tire_readings_tire ON tire_readings (tire_id)") + cur.execute("CREATE INDEX idx_tire_readings_vin ON tire_readings (vin)") + cur.execute("CREATE INDEX idx_tire_readings_mount_period ON tire_readings (mount_period_id)") + + +def _rebuild_vehicle_reminders(cur, checks: list[tuple[str, str]]) -> None: + """Step 7. Add `tire_id`, `source`, and the three low-tread columns. + + The composite FK carries **no `ON DELETE` action**. A referential action + applies to every column in the FK, so `SET NULL` would try to null `vin` + as well -- and `vehicle_reminders.vin` is NOT NULL. Measured: SQLite raises + `NOT NULL constraint failed: vehicle_reminders.vin` and REJECTS the tire + deletion, so "SET NULL solves the delete problem" is exactly backwards; it + makes deleting a tire impossible. The service nulls `tire_id` explicitly in + the same transaction as the delete instead. + + `checks` comes from P2 and may be short: a CHECK the data violates is + skipped rather than crash-looping a FATAL migration. + """ + check_sql = "".join(f",\n CONSTRAINT {n} CHECK ({e})" for n, e in checks) + cur.execute(f""" + CREATE TABLE vehicle_reminders_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vin VARCHAR(17) NOT NULL, + line_item_id INTEGER, + tire_id INTEGER, + source VARCHAR(20), + title VARCHAR(200) NOT NULL, + reminder_type VARCHAR(10) NOT NULL, + due_date DATE, + due_mileage_km NUMERIC(10, 2), + due_hours NUMERIC(10, 1), + tread_depth_mm NUMERIC(5, 2), + tread_threshold_mm NUMERIC(5, 2), + projected_distance_km NUMERIC(10, 2), + status VARCHAR(10) NOT NULL DEFAULT 'pending', + notes TEXT, + last_notified_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP{check_sql}, + FOREIGN KEY (vin) REFERENCES vehicles(vin) ON DELETE CASCADE, + FOREIGN KEY (line_item_id) REFERENCES service_line_items(id) ON DELETE SET NULL, + FOREIGN KEY (tire_id, vin) REFERENCES tires(id, vin) + ) + """) + cur.execute(""" + INSERT INTO vehicle_reminders_new + (id, vin, line_item_id, title, reminder_type, due_date, + due_mileage_km, due_hours, status, notes, last_notified_at, + created_at, updated_at) + SELECT id, vin, line_item_id, title, reminder_type, due_date, + due_mileage_km, due_hours, status, notes, last_notified_at, + created_at, updated_at + FROM vehicle_reminders + """) + cur.execute("DROP TABLE vehicle_reminders") + cur.execute("ALTER TABLE vehicle_reminders_new RENAME TO vehicle_reminders") + cur.execute("CREATE INDEX ix_reminders_vin_status ON vehicle_reminders (vin, status)") + cur.execute("CREATE INDEX ix_reminders_due_date ON vehicle_reminders (due_date)") + cur.execute("CREATE INDEX ix_reminders_due_mileage_km ON vehicle_reminders (due_mileage_km)") + + +def _ensure_new_tables(cur) -> None: + """Step 4. `tire_sets` and `tire_mount_periods`. + + On an upgraded instance `create_all()` has already made these from the ORM + before migrations run, so this is usually a no-op. It exists for the case + where it has not, and because step 3's `tires.set_id` FK and step 6's + `mount_period_id` FK both need the targets to exist first. + + Neither table carries a `vin`. `tire_mount_periods` deliberately has none + (the parent tire is the authority), which is why the "one open period per + corner per vehicle" rule cannot be a database constraint and is enforced in + the service instead. + """ + cur.execute(""" + CREATE TABLE IF NOT EXISTS tire_sets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vin VARCHAR(17) NOT NULL, + name VARCHAR(60) NOT NULL, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (vin) REFERENCES vehicles(vin) ON DELETE CASCADE + ) + """) + cur.execute("CREATE INDEX IF NOT EXISTS idx_tire_sets_vin ON tire_sets (vin)") + cur.execute(""" + CREATE TABLE IF NOT EXISTS tire_mount_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tire_id INTEGER NOT NULL, + position VARCHAR(10) NOT NULL, + mounted_on DATE, + dismounted_on DATE, + mounted_odometer_km NUMERIC(10, 2), + dismounted_odometer_km NUMERIC(10, 2), + is_assumed BOOLEAN NOT NULL DEFAULT 0, + observed_active_on DATE, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (tire_id) REFERENCES tires(id) ON DELETE CASCADE + ) + """) + cur.execute( + "CREATE INDEX IF NOT EXISTS idx_tire_mount_periods_tire ON tire_mount_periods (tire_id)" + ) + cur.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_tire_single_open_period " + "ON tire_mount_periods (tire_id) WHERE dismounted_on IS NULL" + ) + + +def _backfill_periods(cur) -> int: + """Step 5. One assumed open period per existing tire. + + `mounted_on` comes from the staged `installed_date` and is usually NULL -- + the field existed end to end but had no UI, so almost nobody set it. That + NULL is the point: the period asserts "this tire was at this corner as of + the migration date" and nothing about when it got there, which is why + `distance_on_tire` reports `nothing_bounded` for it rather than inventing a + figure. `observed_active_on` records the date the assumption was made. + + Only tires that HAVE a position get one. A tire with a NULL position cannot + exist yet (the column was NOT NULL until step 3), but the filter is written + anyway so a re-run against a partially-migrated database cannot invent a + period at a NULL corner. + """ + cur.execute(""" + INSERT INTO tire_mount_periods + (tire_id, position, mounted_on, dismounted_on, mounted_odometer_km, + dismounted_odometer_km, is_assumed, observed_active_on) + SELECT t.id, t.position, s.installed_date, NULL, NULL, NULL, 1, DATE('now') + FROM tires t + LEFT JOIN _mig097_installed_dates s ON s.tire_id = t.id + WHERE t.position IS NOT NULL + """) + return cur.rowcount + + +def _link_readings_to_periods(cur) -> int: + """Attach existing readings to the assumed period for their tire. + + Every reading predates the migration, and each tire has exactly one period + at this point, so the mapping is unambiguous. Without it every historical + reading would sit with a NULL `mount_period_id` and no surface could + attribute it to anything. + """ + cur.execute(""" + UPDATE tire_readings + SET mount_period_id = ( + SELECT p.id FROM tire_mount_periods p + WHERE p.tire_id = tire_readings.tire_id + ORDER BY p.id LIMIT 1 + ) + WHERE mount_period_id IS NULL + """) + return cur.rowcount + + +def _run_sqlite(engine) -> None: + """The whole migration, on one raw connection, in one transaction. + + `engine.raw_connection()` yields the DB-API connection without SQLAlchemy's + autobegin layer, so `PRAGMA foreign_keys = OFF` lands OUTSIDE a transaction + where it actually takes effect. Inside one it is silently ignored and the + read still returns 1 -- measured -- and the `DROP TABLE tires` in step 3 + would then cascade every `tire_readings` row away without a word. + """ + raw = engine.raw_connection() + try: + cur = raw.cursor() + + cur.execute("PRAGMA foreign_keys = OFF") + fk_state = cur.execute("PRAGMA foreign_keys").fetchone()[0] + if fk_state != 0: + raise RuntimeError( + f"PRAGMA foreign_keys = OFF failed; got {fk_state}. " + "Are we inside an active transaction? Proceeding would let " + "DROP TABLE tires cascade away every tire_readings row." + ) + + try: + cur.execute("BEGIN") + + # 1. Preflight. + _preflight_vin_mismatches(cur) + checks = _preflight_reminder_checks(cur) + + # 2. Stage installed_date; step 3 drops the column it came from. + cur.execute("DROP TABLE IF EXISTS _mig097_installed_dates") + cur.execute( + "CREATE TABLE _mig097_installed_dates " + "(tire_id INTEGER PRIMARY KEY, installed_date DATE)" + ) + cur.execute( + "INSERT INTO _mig097_installed_dates (tire_id, installed_date) " + "SELECT id, installed_date FROM tires" + ) + + # 4 before 3: step 3's set_id FK needs tire_sets to exist. + _ensure_new_tables(cur) + # 3. + _rebuild_tires(cur) + # 5. + periods = _backfill_periods(cur) + # 6. + _rebuild_tire_readings(cur) + linked = _link_readings_to_periods(cur) + # 7. + _rebuild_vehicle_reminders(cur, checks) + + cur.execute("DROP TABLE _mig097_installed_dates") + + violations = cur.execute("PRAGMA foreign_key_check").fetchall() + if violations: + raise RuntimeError( + f"FK violations after the tire rebuilds (pre-commit): {violations!r}" + ) + + cur.execute("COMMIT") + print( + f" → 097: {periods} assumed mount period(s) created, " + f"{linked} reading(s) linked, " + f"{len(checks)}/{len(REMINDER_CHECKS)} reminder CHECK(s) installed" + ) + except Exception: + cur.execute("ROLLBACK") + raise + finally: + cur.execute("PRAGMA foreign_keys = ON") + + fk_state = cur.execute("PRAGMA foreign_keys").fetchone()[0] + if fk_state != 1: + raise RuntimeError(f"PRAGMA foreign_keys = ON failed; got {fk_state}.") + finally: + raw.close() + + +# ============================================================================ +# PostgreSQL +# ============================================================================ + + +def _run_postgres(engine) -> None: + """PostgreSQL has real ALTER TABLE, so nothing is rebuilt. + + That difference matters for the CHECKs: on SQLite an omitted constraint + vanishes with the old table, whereas here the existing ones are untouched + and only the missing ones are added. A database created by `create_all` + has none of the three (the ORM declares no CheckConstraint), while a + migrated one has all three, so both are handled by adding what is absent. + """ + with engine.begin() as conn: + _pg_preflight_vin_mismatches(conn) + + conn.execute(text("ALTER TABLE tires ALTER COLUMN position DROP NOT NULL")) + conn.execute(text("ALTER TABLE tires ADD COLUMN IF NOT EXISTS set_id INTEGER")) + conn.execute(text("ALTER TABLE tires ADD COLUMN IF NOT EXISTS retired_on DATE")) + conn.execute( + text( + "DO $$ BEGIN " + "ALTER TABLE tires ADD CONSTRAINT uq_tires_id_vin UNIQUE (id, vin); " + "EXCEPTION WHEN duplicate_table THEN NULL; " + "WHEN duplicate_object THEN NULL; END $$" + ) + ) + conn.execute( + text( + "DO $$ BEGIN " + "ALTER TABLE tires ADD CONSTRAINT fk_tires_set_id " + "FOREIGN KEY (set_id) REFERENCES tire_sets(id) ON DELETE SET NULL; " + "EXCEPTION WHEN duplicate_object THEN NULL; END $$" + ) + ) + + conn.execute(text("ALTER TABLE tire_readings ALTER COLUMN position DROP NOT NULL")) + conn.execute( + text("ALTER TABLE tire_readings ADD COLUMN IF NOT EXISTS mount_period_id INTEGER") + ) + conn.execute( + text( + "DO $$ BEGIN " + "ALTER TABLE tire_readings ADD CONSTRAINT fk_tire_readings_mount_period " + "FOREIGN KEY (mount_period_id) REFERENCES tire_mount_periods(id) " + "ON DELETE SET NULL; " + "EXCEPTION WHEN duplicate_object THEN NULL; END $$" + ) + ) + conn.execute( + text( + "DO $$ BEGIN " + "ALTER TABLE tire_readings ADD CONSTRAINT fk_tire_readings_tire_vin " + "FOREIGN KEY (tire_id, vin) REFERENCES tires(id, vin) ON DELETE CASCADE; " + "EXCEPTION WHEN duplicate_object THEN NULL; END $$" + ) + ) + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_tire_readings_mount_period " + "ON tire_readings (mount_period_id)" + ) + ) + + for column, ddl in ( + ("tire_id", "INTEGER"), + ("source", "VARCHAR(20)"), + ("tread_depth_mm", "NUMERIC(5, 2)"), + ("tread_threshold_mm", "NUMERIC(5, 2)"), + ("projected_distance_km", "NUMERIC(10, 2)"), + ): + conn.execute( + text(f"ALTER TABLE vehicle_reminders ADD COLUMN IF NOT EXISTS {column} {ddl}") + ) + conn.execute( + text( + "DO $$ BEGIN " + "ALTER TABLE vehicle_reminders ADD CONSTRAINT fk_reminders_tire_vin " + "FOREIGN KEY (tire_id, vin) REFERENCES tires(id, vin); " + "EXCEPTION WHEN duplicate_object THEN NULL; END $$" + ) + ) + # Same P2 policy as SQLite: add only the CHECKs the data can take. + for name, expression, scan in REMINDER_CHECKS: + violations = [r[0] for r in conn.execute(text(scan)).fetchall()] + if violations: + print( + f" → P2: SKIPPING {name}; {len(violations)} row(s) violate it: {violations}." + ) + continue + conn.execute( + text( + f"DO $$ BEGIN " + f"ALTER TABLE vehicle_reminders ADD CONSTRAINT {name} " + f"CHECK ({expression}); " + f"EXCEPTION WHEN duplicate_object THEN NULL; END $$" + ) + ) + + conn.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_tire_single_open_period " + "ON tire_mount_periods (tire_id) WHERE dismounted_on IS NULL" + ) + ) + + # Steps 5 and its reading links, then drop the source column. + result = conn.execute( + text( + "INSERT INTO tire_mount_periods " + "(tire_id, position, mounted_on, dismounted_on, mounted_odometer_km, " + " dismounted_odometer_km, is_assumed, observed_active_on) " + "SELECT t.id, t.position, t.installed_date, NULL, NULL, NULL, true, " + " CURRENT_DATE " + "FROM tires t WHERE t.position IS NOT NULL" + ) + ) + conn.execute( + text( + "UPDATE tire_readings SET mount_period_id = (" + " SELECT p.id FROM tire_mount_periods p " + " WHERE p.tire_id = tire_readings.tire_id ORDER BY p.id LIMIT 1) " + "WHERE mount_period_id IS NULL" + ) + ) + conn.execute(text("ALTER TABLE tires DROP COLUMN IF EXISTS installed_date")) + print(f" → 097: {result.rowcount} assumed mount period(s) created") + + +def _pg_preflight_vin_mismatches(conn) -> None: + """P1 on PostgreSQL. Same repair, expressed as an UPDATE ... FROM.""" + for table in ("tire_readings", "vehicle_reminders"): + has_tire_id = conn.execute( + text( + "SELECT 1 FROM information_schema.columns " + "WHERE table_name = :t AND column_name = 'tire_id'" + ), + {"t": table}, + ).first() + if not has_tire_id: + continue + bad = [ + r[0] + for r in conn.execute( + text( + f"SELECT c.id FROM {table} c JOIN tires t ON t.id = c.tire_id " + f"WHERE c.vin <> t.vin" + ) + ).fetchall() + ] + if not bad: + continue + print(f" → P1: repairing {len(bad)} {table} row(s) with a mismatched vin: {bad}") + conn.execute( + text( + f"UPDATE {table} c SET vin = t.vin FROM tires t " + f"WHERE t.id = c.tire_id AND c.vin <> t.vin" + ) + ) + + +# ============================================================================ +# Entry point +# ============================================================================ + + +def upgrade(engine=None) -> None: + """Run the migration, or return cleanly if it has already been applied. + + RE-ENTRANCY. With steps 1-7 in one transaction the observable states + collapse to two, and the discriminator is the LAST thing this migration + writes (`vehicle_reminders.source`), not the first. + + Keying on the presence of `tire_mount_periods` would be wrong: `create_all` + creates that table from the ORM on every upgrade BEFORE migrations run, so + "periods exist" is true of a database that has done nothing. Keying on + `tires.installed_date` alone would be wrong the other way: a fresh + `create_all` database never had the column, and would look mid-migration. + + | Observed | Meaning | Action | + |---|---|---| + | `vehicle_reminders.source` exists | 097 finished (or fresh create_all) | return | + | otherwise | legacy schema | run steps 1-7 | + """ + if engine is None: + engine = _get_fallback_engine() + + inspector = inspect(engine) + if not inspector.has_table("tires"): + print(" → tires missing; skip (run the earlier migrations first)") + return + + reminder_columns = {c["name"] for c in inspector.get_columns("vehicle_reminders")} + if "source" in reminder_columns: + print(" → 097 already applied (vehicle_reminders.source present); nothing to do") + return + + _preflight_backup_marker(engine) + + if engine.dialect.name == "postgresql": + _run_postgres(engine) + else: + _run_sqlite(engine) diff --git a/backend/app/migrations/098_session_boundaries.py b/backend/app/migrations/098_session_boundaries.py new file mode 100644 index 00000000..c73c00ad --- /dev/null +++ b/backend/app/migrations/098_session_boundaries.py @@ -0,0 +1,270 @@ +"""Durable movement state, session provenance, and one open session per device. + +The schema half of the session-boundary rework. A drive session used to open on +*contact* -- any sign the dongle could reach the broker -- and a parked WiCAN +publishes a battery-voltage heartbeat roughly every 95 minutes, so 83% of +recorded sessions (2,975 of 3,238 on this instance) were a heartbeat rather than +a drive, while real drives out of broker range were missed entirely. + +Deciding sessions on *movement* instead needs state that outlives a request: +the MQTT subscriber, the HTTPS route and the scheduler are three execution +contexts, so an in-memory movement candidate is invisible to two of them and is +lost on every container restart. Hence five columns on ``livelink_devices``. + +Sessions also gain provenance. Every row that exists today was cut by the old +rule, so ``boundary_algorithm_version`` defaults to **0** -- "pre-098 +semantics". Getting that default backwards would make every historic session +look already-correct and be skipped by any future pass over history, forever. + +NOT FATAL +--------- +Deliberately. The partial unique index is the one step that can fail on live +data, and an instance without it is still a working instance -- the race it +prevents is rare -- whereas a crash-looping instance is not usable at all. The +runner logs and continues. + +THE PREFLIGHT IS AN INVENTORY, NOT A DUPLICATE SCAN +--------------------------------------------------- +``uq_drive_sessions_open_per_device`` can fail on existing data, and "close the +older duplicates" is not sufficient: + +- ``livelink_devices.current_session_id`` points at one open session, and in the + race that pointer can belong to the OLDER row. Closing by timestamp then + closes the session the device is actively writing to and keeps the orphan. +- There are also **singleton** orphans: ``LiveLinkService.unlink_device`` clears + the pointer without closing the session. That violates nothing today, so a + duplicate scan does not see it -- but the session can never be closed by any + live path again, and it would reject every future session start for that + device once the index exists. + +So every open row is inventoried, the retained row is the one the pointer names +(if open) or else the newest, every other open row is closed at its own last +telemetry sample clamped against the retained session's start, and the pointer +is repaired in the same transaction. + +Back up before deploying, with the backup API (``POST /api/backup/create-full``), +not ``cp``: MyGarage runs in WAL mode and a plain file copy of a database with a +live WAL sidecar is torn but plausible. +""" + +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path + +from sqlalchemy import create_engine, inspect, text + +FATAL = False + +#: (table, column, DDL type). Nullable in every case except the version column, +#: which carries a NOT NULL DEFAULT 0 -- see the module docstring. +_NEW_COLUMNS: tuple[tuple[str, str, str], ...] = ( + # Movement state, per device. All five reset together (see the state + # machine in SessionService); splitting them across migrations is what left + # an earlier revision of the design unbuildable from its own schema. + ("livelink_devices", "last_movement_at", "TIMESTAMP"), + ("livelink_devices", "pending_since", "TIMESTAMP"), + ("livelink_devices", "pending_source", "VARCHAR(10)"), + ("livelink_devices", "movement_candidate_at", "TIMESTAMP"), + ("livelink_devices", "movement_baseline_km", "NUMERIC(10,2)"), + # Session provenance and true movement bounds. + ("drive_sessions", "movement_started_at", "TIMESTAMP"), + ("drive_sessions", "movement_ended_at", "TIMESTAMP"), + ("drive_sessions", "boundary_algorithm_version", "INTEGER NOT NULL DEFAULT 0"), + ("drive_sessions", "effective_gap_minutes", "INTEGER"), +) + + +def _get_fallback_engine(): + db_path = os.environ.get("DATABASE_PATH") + if db_path: + return create_engine(f"sqlite:///{db_path}") + data_dir = Path(os.getenv("DATA_DIR", "/data")) + return create_engine(f"sqlite:///{data_dir / 'mygarage.db'}") + + +def _add_missing_columns(conn, inspector) -> int: + """``ALTER TABLE ADD COLUMN`` for each absent column. Identical on both dialects. + + ``NOT NULL DEFAULT 0`` on ADD COLUMN is accepted by SQLite (3.32+) and + PostgreSQL alike and fills existing rows, which is what makes + ``boundary_algorithm_version`` land as 0 on history without a second UPDATE. + """ + added = 0 + for table, column, ddl_type in _NEW_COLUMNS: + if not inspector.has_table(table): + print(f" → {table} missing; skip {column}") + continue + if column in {c["name"] for c in inspector.get_columns(table)}: + continue + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl_type}")) + print(f" ✓ Added {table}.{column}") + added += 1 + return added + + +def _last_telemetry_by_device(conn) -> dict[str, datetime]: + """The newest telemetry timestamp per device, for closing orphans honestly. + + One grouped query rather than one per orphan: a database with a few hundred + orphans should not issue a few hundred round trips during startup. + """ + rows = conn.execute( + text("SELECT device_id, MAX(timestamp) FROM vehicle_telemetry GROUP BY device_id") + ) + out: dict[str, datetime] = {} + for device_id, stamp in rows: + if device_id is None or stamp is None: + continue + out[device_id] = ( + stamp if isinstance(stamp, datetime) else datetime.fromisoformat(str(stamp)) + ) + return out + + +def _coerce(value) -> datetime | None: + if value is None or isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value)) + + +def _preflight_open_sessions(conn, inspector) -> tuple[int, int]: + """Reconcile every open session so the partial unique index can be created. + + Returns ``(sessions_closed, pointers_repaired)``. + """ + if not inspector.has_table("drive_sessions"): + return 0, 0 + + open_rows = list( + conn.execute( + text( + "SELECT id, device_id, started_at FROM drive_sessions " + "WHERE ended_at IS NULL ORDER BY device_id, started_at, id" + ) + ) + ) + if not open_rows: + return 0, 0 + + pointers: dict[str, int | None] = {} + if inspector.has_table("livelink_devices"): + pointers = { + row[0]: row[1] + for row in conn.execute( + text("SELECT device_id, current_session_id FROM livelink_devices") + ) + } + + last_seen_sample = ( + _last_telemetry_by_device(conn) if inspector.has_table("vehicle_telemetry") else {} + ) + + by_device: dict[str, list[tuple[int, datetime | None]]] = {} + for session_id, device_id, started_at in open_rows: + by_device.setdefault(device_id, []).append((session_id, _coerce(started_at))) + + closed = 0 + repaired = 0 + for device_id, sessions in sorted(by_device.items()): + open_ids = {sid for sid, _ in sessions} + pointed_at = pointers.get(device_id) + + if device_id not in pointers: + # No device row at all. `drive_sessions.device_id` carries no FK and + # device deletion deliberately retains history, so this is reachable + # -- and no live path can ever close these again. + retained_id = None + elif pointed_at in open_ids: + retained_id = pointed_at + else: + # Newest by started_at, ties broken by id. A NULL started_at cannot + # happen (NOT NULL) but sorts first defensively rather than raising. + retained_id = max(sessions, key=lambda pair: (pair[1] or datetime.min, pair[0]))[0] + + retained_start = next((start for sid, start in sessions if sid == retained_id), None) + + for session_id, started_at in sessions: + if session_id == retained_id: + continue + # Its own last telemetry, not `now`: closing a session from March at + # the upgrade timestamp would invent months of drive. + end_at = last_seen_sample.get(device_id) or started_at + if started_at is not None and (end_at is None or end_at < started_at): + end_at = started_at + # Clamped against the retained session, because the two windows + # genuinely overlap in the race. Two sessions claiming the same + # telemetry both report the same distance, and every aggregate here + # is a window scan. + if retained_start is not None and end_at is not None and end_at > retained_start: + end_at = retained_start + duration = None + if started_at is not None and end_at is not None: + duration = max(0, int((end_at - started_at).total_seconds())) + conn.execute( + text( + "UPDATE drive_sessions SET ended_at = :e, duration_seconds = :d WHERE id = :i" + ), + {"e": end_at, "d": duration, "i": session_id}, + ) + closed += 1 + print(f" ✓ Closed orphaned open session {session_id} ({device_id}) at {end_at}") + + if retained_id is not None and pointed_at != retained_id: + conn.execute( + text("UPDATE livelink_devices SET current_session_id = :s WHERE device_id = :d"), + {"s": retained_id, "d": device_id}, + ) + repaired += 1 + print(f" ✓ Repaired {device_id}.current_session_id -> {retained_id}") + + return closed, repaired + + +def _create_indexes(conn, inspector) -> None: + """The movement index and the partial unique index. + + ``CREATE UNIQUE INDEX ... WHERE`` is supported by SQLite and PostgreSQL in + the same syntax, so no dialect branch is needed here. + """ + if inspector.has_table("livelink_devices"): + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_livelink_devices_last_movement_at " + "ON livelink_devices (last_movement_at)" + ) + ) + if inspector.has_table("drive_sessions"): + conn.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_drive_sessions_open_per_device " + "ON drive_sessions (device_id) WHERE ended_at IS NULL" + ) + ) + print(" ✓ One open session per device is now a constraint") + + +def upgrade(engine=None) -> None: + """Add the columns, reconcile open sessions, then take the index.""" + if engine is None: + engine = _get_fallback_engine() + + inspector = inspect(engine) + + # One transaction: the runner stamps `schema_migrations` in a SEPARATE + # transaction, so a crash between two non-transactional steps would leave a + # schema matching neither branch of the re-entrancy check. SQLite supports + # transactional DDL, so this holds on both dialects. + with engine.begin() as conn: + _add_missing_columns(conn, inspector) + + # Re-inspect: the preflight and index steps read columns this + # transaction just added. + inspector = inspect(engine) + + closed, repaired = _preflight_open_sessions(conn, inspector) + if closed or repaired: + print(f" ✓ Preflight: closed {closed} orphan(s), repaired {repaired} pointer(s)") + + _create_indexes(conn, inspector) diff --git a/backend/app/migrations/099_add_vehicle_fuel_filter.py b/backend/app/migrations/099_add_vehicle_fuel_filter.py new file mode 100644 index 00000000..ec90c7fc --- /dev/null +++ b/backend/app/migrations/099_add_vehicle_fuel_filter.py @@ -0,0 +1,63 @@ +"""Add ``vehicles.fuel_filter_part_number`` to the maintenance specs. + +Deliberately not gated on fuel type, though a diesel is what prompted it. A +6.7 Cummins carries two serviceable fuel filters and they are a scheduled item; +a modern petrol car usually has a lifetime in-tank filter and never needs one. +But "usually" is not "never": older petrol vehicles run serviceable inline +filters, and a column that exists only for diesels cannot record those. The card +renders a spec only when it holds a value, so a vehicle that has no fuel filter +simply never shows the row. + +FATAL for migration 095's reason: the Vehicle ORM maps this column, so every +vehicle SELECT includes it and a silent skip would 500 all vehicle reads after +the model change. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from sqlalchemy import create_engine, inspect, text + +FATAL = True + +#: Matches ``oil_filter_part_number``: part numbers are short alphanumeric +#: strings, and the two fields are entered side by side. +_COLUMNS: tuple[tuple[str, str], ...] = (("fuel_filter_part_number", "VARCHAR(50)"),) + + +def _get_fallback_engine(): + db_path = os.environ.get("DATABASE_PATH") + if db_path: + return create_engine(f"sqlite:///{db_path}") + data_dir = Path(os.getenv("DATA_DIR", "/data")) + return create_engine(f"sqlite:///{data_dir / 'mygarage.db'}") + + +def upgrade(engine=None) -> None: + if engine is None: + engine = _get_fallback_engine() + + inspector = inspect(engine) + if not inspector.has_table("vehicles"): + return + + existing = {c["name"] for c in inspector.get_columns("vehicles")} + missing = [(name, ddl) for name, ddl in _COLUMNS if name not in existing] + if not missing: + print("✓ vehicles.fuel_filter_part_number already present") + return + + with engine.begin() as conn: + for name, ddl in missing: + conn.execute(text(f"ALTER TABLE vehicles ADD COLUMN {name} {ddl}")) + print(f"✓ Added vehicles.{name}") + + +def downgrade(): # pragma: no cover + raise NotImplementedError("Migration 099 is forward-only.") + + +if __name__ == "__main__": + upgrade() diff --git a/backend/app/migrations/INDEX.md b/backend/app/migrations/INDEX.md index d40e2d83..f297dfe6 100644 --- a/backend/app/migrations/INDEX.md +++ b/backend/app/migrations/INDEX.md @@ -107,3 +107,6 @@ migrations must swallow their own operational errors. | `094_nullable_reading_tread` | **FATAL** — Allow NULL tire_readings.tread_depth_mm for pressure-only readings. | | `095_add_vehicle_maintenance_specs` | **FATAL** — Add structured maintenance-spec columns on vehicles (oil, torque, fluids). | | `096_add_device_odometer_unit` | **FATAL** — Add livelink_devices.odometer_unit and backfill it from observed param keys. | +| `097_tire_mount_periods` | **FATAL** — Give tires a mount history, and make `position` mean "where it is now". | +| `098_session_boundaries` | Durable movement state, session provenance, and one open session per device. | +| `099_add_vehicle_fuel_filter` | **FATAL** — Add ``vehicles.fuel_filter_part_number`` to the maintenance specs. | diff --git a/backend/app/models/drive_session.py b/backend/app/models/drive_session.py index 90e1b110..e61acbab 100644 --- a/backend/app/models/drive_session.py +++ b/backend/app/models/drive_session.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String +from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, text from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func @@ -63,6 +63,33 @@ class DriveSession(Base): harsh_accel_count: Mapped[int | None] = mapped_column(Integer) harsh_brake_count: Mapped[int | None] = mapped_column(Integer) + # True movement bounds, distinct from the contact window above. + # + # `started_at`/`ended_at` are the first and last sample of the CONTACT + # burst, which is what every aggregate is computed from -- narrowing them to + # the first movement sample would drop warm-up coolant, initial fuel level + # and, critically, the OPENING ODOMETER READING, leaving a window with one + # odometer sample and a confident `distance_km = 0.0`. These two record when + # the vehicle actually moved. NULL means "unknown", not "did not move": + # every session predating migration 098 has no answer. + movement_started_at: Mapped[datetime | None] = mapped_column(DateTime) + movement_ended_at: Mapped[datetime | None] = mapped_column(DateTime) + + # How this session's boundaries were decided. 0 = pre-098 semantics, cut on + # contact; 1 = the movement predicate. Torque sessions stay 0 because their + # boundaries come from the phone, not from this algorithm, and stamping them + # 1 would claim a provenance they do not have. + # + # Every constructor must set this. Defaulting new rows to 0 would make them + # masquerade as pre-098 history, which any later pass over history would + # then skip as already-correct. + boundary_algorithm_version: Mapped[int] = mapped_column( + Integer, nullable=False, server_default=text("0"), default=0 + ) + #: The gap threshold in force when this session was cut. NULL means the old + #: contact timeout applied. + effective_gap_minutes: Mapped[int | None] = mapped_column(Integer) + # Metadata created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) @@ -85,4 +112,16 @@ class DriveSession(Base): Index("idx_sessions_device", "device_id", "started_at"), Index("idx_sessions_ended", "ended_at"), Index("uq_drive_session_external", "device_id", "external_session_id", unique=True), + # One OPEN session per device, as a constraint rather than a convention. + # Two concurrent first-movement payloads (MQTT and HTTPS can race) both + # read a NULL `current_session_id`, both create; one wins the pointer and + # the other is orphaned open forever. Partial, so the second and every + # later CLOSED session of a device stay legal. + Index( + "uq_drive_sessions_open_per_device", + "device_id", + unique=True, + sqlite_where=text("ended_at IS NULL"), + postgresql_where=text("ended_at IS NULL"), + ), ) diff --git a/backend/app/models/livelink_device.py b/backend/app/models/livelink_device.py index 1e6a1d11..a22b6add 100644 --- a/backend/app/models/livelink_device.py +++ b/backend/app/models/livelink_device.py @@ -3,9 +3,20 @@ """LiveLink WiCAN device model.""" from datetime import datetime +from decimal import Decimal from typing import TYPE_CHECKING -from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, text +from sqlalchemy import ( + Boolean, + DateTime, + Float, + ForeignKey, + Index, + Integer, + Numeric, + String, + text, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func @@ -80,6 +91,32 @@ class LiveLinkDevice(Base): # Session grace period (WiFi drop resilience) pending_offline_at: Mapped[datetime | None] = mapped_column(DateTime) + # Movement state (migration 098). Durable, per device, and never in process + # memory: the MQTT subscriber, the HTTPS route and the scheduler are three + # execution contexts, so an in-memory candidate is invisible to two of them + # and is lost on every restart -- which silently converts "keep the warm-up + # samples" into "drop them" every time the container cycles. + # + # Scoped per DEVICE, never per VIN: a vehicle carrying both a WiCAN dongle + # and a Torque phone would otherwise let one source confirm the other's + # pending drive. + # + # All four pending fields reset together — on promotion to a session, on + # expiry past the drive gap, and when an explicit offline FINALIZES (not + # when it arrives, or a brief WiFi drop inside the grace period would + # discard the warm-up samples this state exists to preserve). + last_movement_at: Mapped[datetime | None] = mapped_column(DateTime, index=True) + #: Engine on, nothing moving yet. NULL = no pending drive. + pending_since: Mapped[datetime | None] = mapped_column(DateTime) + #: Which signal opened the pending drive. Only ``'rpm'`` is written today: + #: a pending drive IS "engine on, nothing moving yet", and a sample above the + #: movement floor confirms movement outright rather than opening one. + pending_source: Mapped[str | None] = mapped_column(String(10)) + #: First of the two consecutive above-floor speed samples the debounce needs. + movement_candidate_at: Mapped[datetime | None] = mapped_column(DateTime) + #: Odometer at pending open, for the odometer-increase movement signal. + movement_baseline_km: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) + # State enabled: Mapped[bool] = mapped_column(Boolean, default=True) last_seen: Mapped[datetime | None] = mapped_column(DateTime) diff --git a/backend/app/models/reminder.py b/backend/app/models/reminder.py index f0a1fae3..ba29ca1c 100644 --- a/backend/app/models/reminder.py +++ b/backend/app/models/reminder.py @@ -6,7 +6,18 @@ from decimal import Decimal from typing import TYPE_CHECKING -from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text +from sqlalchemy import ( + CheckConstraint, + Date, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + Numeric, + String, + Text, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func @@ -35,6 +46,24 @@ class Reminder(Base): due_mileage_km: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True) # Hours target for reminder_type='hours' (hour-metered vehicles). Migration 083. due_hours: Mapped[Decimal | None] = mapped_column(Numeric(10, 1), nullable=True) + # Which tire this reminder is about, for tire-sourced reminders (097). + # Part of a COMPOSITE FK to `tires (id, vin)` declared in __table_args__, + # so a reminder cannot name a tire belonging to a different vehicle. + # + # That FK deliberately carries NO `ON DELETE` action. A referential action + # applies to every column in the FK, so SET NULL would try to null `vin` + # too -- and `vin` is NOT NULL, which makes SQLite reject the tire deletion + # outright. The service nulls `tire_id` explicitly in the same transaction + # as the delete instead, which keeps the reminder as history. + tire_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + # What created this reminder: 'low_tread' for the tire sync, NULL for a + # reminder a human made. The sync never adopts a row whose source is NULL. + source: Mapped[str | None] = mapped_column(String(20), nullable=True) + # Canonical snapshots taken when a low-tread reminder was raised, so the + # notification can say what it saw without re-deriving it later. + tread_depth_mm: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True) + tread_threshold_mm: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True) + projected_distance_km: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True) status: Mapped[str] = mapped_column(String(10), nullable=False, default="pending") notes: Mapped[str | None] = mapped_column(Text, nullable=True) last_notified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) @@ -48,6 +77,25 @@ class Reminder(Base): source_line_item: Mapped[ServiceLineItem | None] = relationship("ServiceLineItem") __table_args__ = ( + # Declared in the TABLE-level form on purpose: SQLAlchemy's SQLite + # reflection parses `ON DELETE` only out of this form, so an inline + # declaration reflects as `options: {}` and diverges from create_all + # (measured in migration 094). + ForeignKeyConstraint( + ["tire_id", "vin"], ["tires.id", "tires.vin"], name="fk_reminders_tire_vin" + ), + # All three CHECKs, matching what migration 083 wrote. The ORM declared + # NONE of them before v3.3.0, so a `create_all` database had zero while + # a migrated one had three -- a divergence that let the legacy JSON + # importer write a negative `due_mileage_km` on fresh installs only. + CheckConstraint( + "reminder_type IN ('date','mileage','both','smart','hours')", + name="check_reminder_type", + ), + CheckConstraint("status IN ('pending','done','dismissed')", name="check_reminder_status"), + CheckConstraint( + "due_mileage_km IS NULL OR due_mileage_km > 0", name="check_due_mileage_km" + ), Index("ix_reminders_vin_status", "vin", "status"), Index("ix_reminders_due_date", "due_date"), Index("ix_reminders_due_mileage_km", "due_mileage_km"), diff --git a/backend/app/models/tire.py b/backend/app/models/tire.py index 9077d0e0..640f15ff 100644 --- a/backend/app/models/tire.py +++ b/backend/app/models/tire.py @@ -7,6 +7,7 @@ from decimal import Decimal from sqlalchemy import ( + Boolean, Date, DateTime, ForeignKey, @@ -16,6 +17,7 @@ String, Text, UniqueConstraint, + text, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func @@ -23,6 +25,87 @@ from app.database import Base +class TireSet(Base): + """A named group of tires, so a seasonal swap is one action not eight. + + UX grouping only (D6). No calculation depends on set membership: distance, + wear and position all read `tire_mount_periods`. A set exists so the user + can say "Winter studded" and swap four tires at once. + """ + + __tablename__ = "tire_sets" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + vin: Mapped[str] = mapped_column( + String(17), ForeignKey("vehicles.vin", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(60), nullable=False) + notes: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + tires: Mapped[list[Tire]] = relationship("Tire", back_populates="tire_set") + + __table_args__ = (Index("idx_tire_sets_vin", "vin"),) + + +class TireMountPeriod(Base): + """One interval during which a tire was mounted at one position. + + The half-open interval ``[mounted_on, dismounted_on)``: a tire dismounted + and remounted on the same day yields an empty period, which is correct -- + it accrued no distance. + + Deliberately carries **no `vin`**. The parent tire has one, and duplicating + it would create a second place for the two to disagree. The cost is stated + in the design (D4): the "one open period per corner per vehicle" rule + cannot be written as a database constraint here, because the constraint + would need a `vin` this table does not have. It is enforced in the service + under the parent-tire row lock, and it has its own test, because no index + will catch it. + """ + + __tablename__ = "tire_mount_periods" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tire_id: Mapped[int] = mapped_column( + Integer, ForeignKey("tires.id", ondelete="CASCADE"), nullable=False + ) + # Where the tire sat during this period. NOT nullable: a period exists + # because the tire was mounted somewhere. A stored tire has no open period. + position: Mapped[str] = mapped_column(String(10), nullable=False) + # Nullable because migration 097 backfills from `tires.installed_date`, + # which is itself nullable and unset on most rows. + mounted_on: Mapped[dt.date | None] = mapped_column(Date) + dismounted_on: Mapped[dt.date | None] = mapped_column(Date) + mounted_odometer_km: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) + dismounted_odometer_km: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) + # True for the period 097 invents for an existing tire. Such a period + # asserts only "this tire was mounted here as of the migration date"; its + # start is unknown, which is why `distance_on_tire` reports + # `nothing_bounded` rather than a confident figure. + is_assumed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + # The date the assumption was made, so an assumed period can say when it + # was last known to be true even though it cannot say when it began. + observed_active_on: Mapped[dt.date | None] = mapped_column(Date) + notes: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + tire: Mapped[Tire] = relationship("Tire", back_populates="mount_periods") + + __table_args__ = ( + Index("idx_tire_mount_periods_tire", "tire_id"), + # One OPEN period per tire. A tire cannot be mounted in two places at + # once. Partial unique index: valid on both SQLite and PostgreSQL. + Index( + "uq_tire_single_open_period", + "tire_id", + unique=True, + sqlite_where=text("dismounted_on IS NULL"), + postgresql_where=text("dismounted_on IS NULL"), + ), + ) + + class Tire(Base): """Current tire mounted at a vehicle position (one row per position).""" @@ -32,14 +115,36 @@ class Tire(Base): vin: Mapped[str] = mapped_column( String(17), ForeignKey("vehicles.vin", ondelete="CASCADE"), nullable=False ) - # FL / FR / RL / RR / SPARE - position: Mapped[str] = mapped_column(String(10), nullable=False) + # FL / FR / RL / RR / SPARE, or NULL for a tire that is off the vehicle. + # + # Nullable since migration 097 (D2b): "in storage" is a real state a tire + # spends half the year in, and the pre-097 schema could not express it -- + # a seasonal set had to be deleted and re-entered, losing its history. + # + # `uq_tires_vin_position` is KEPT and needs no partial-index replacement: + # NULLs compare as distinct under UNIQUE on both SQLite and PostgreSQL, so + # once this column is nullable the constraint permits any number of stored + # tires per vehicle while still rejecting a second MOUNTED tire at one + # corner. It becomes a mounted-only constraint for free. + position: Mapped[str | None] = mapped_column(String(10)) brand: Mapped[str | None] = mapped_column(String(80)) model_name: Mapped[str | None] = mapped_column(String(80)) size: Mapped[str | None] = mapped_column(String(40)) # DOT week/year code, e.g. "2324" dot_code: Mapped[str | None] = mapped_column(String(20)) - installed_date: Mapped[dt.date | None] = mapped_column(Date) + # `installed_date` was dropped by migration 097 (D12). It is DERIVED in the + # response from the earliest mount period that has a `mounted_on`, so there + # are not two writable sources for the same fact. Deliberately absent from + # this model: leaving it here would put the column on a fresh `create_all` + # database and make that database match 097's "legacy schema" re-entrancy + # branch, running a seven-step rebuild against a schema already correct. + set_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("tire_sets.id", ondelete="SET NULL") + ) + # Set when a tire is retired rather than deleted (D18). A retired tire + # keeps every reading and mount period; hard DELETE is reserved for a tire + # entered by mistake. + retired_on: Mapped[dt.date | None] = mapped_column(Date) # Latest tread depth in millimetres (canonical). tread_depth_mm: Mapped[Decimal | None] = mapped_column(Numeric(5, 2)) # Optional cold pressure in kPa (canonical). @@ -58,8 +163,19 @@ class Tire(Base): cascade="all, delete-orphan", ) + mount_periods: Mapped[list[TireMountPeriod]] = relationship( + "TireMountPeriod", + back_populates="tire", + cascade="all, delete-orphan", + order_by="TireMountPeriod.mounted_on", + ) + tire_set: Mapped[TireSet | None] = relationship("TireSet", back_populates="tires") + __table_args__ = ( UniqueConstraint("vin", "position", name="uq_tires_vin_position"), + # The composite target for `tire_readings (tire_id, vin)`, so a reading + # cannot reference a tire belonging to a different vehicle. + UniqueConstraint("id", "vin", name="uq_tires_id_vin"), Index("idx_tires_vin", "vin"), ) @@ -76,7 +192,15 @@ class TireReading(Base): vin: Mapped[str] = mapped_column( String(17), ForeignKey("vehicles.vin", ondelete="CASCADE"), nullable=False ) - position: Mapped[str] = mapped_column(String(10), nullable=False) + # A SNAPSHOT of where the tire was when the reading was taken, not a live + # pointer. Nullable since 097: a stored tire can still be measured. + position: Mapped[str | None] = mapped_column(String(10)) + # Which mount period this reading falls in. Nullable: readings taken before + # 097 have no period to attribute them to, and a reading on a stored tire + # has none by definition. + mount_period_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("tire_mount_periods.id", ondelete="SET NULL") + ) recorded_at: Mapped[dt.date] = mapped_column(Date, nullable=False) odometer_km: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) # Nullable since migration 094: a reader with no tread gauge logs pressure @@ -93,4 +217,5 @@ class TireReading(Base): __table_args__ = ( Index("idx_tire_readings_tire", "tire_id"), Index("idx_tire_readings_vin", "vin"), + Index("idx_tire_readings_mount_period", "mount_period_id"), ) diff --git a/backend/app/models/vehicle.py b/backend/app/models/vehicle.py index 6fc9ecca..6d7b26ba 100644 --- a/backend/app/models/vehicle.py +++ b/backend/app/models/vehicle.py @@ -135,6 +135,9 @@ class Vehicle(Base): oil_viscosity: Mapped[str | None] = mapped_column(String(30)) oil_capacity_liters: Mapped[Decimal | None] = mapped_column(Numeric(5, 2)) oil_filter_part_number: Mapped[str | None] = mapped_column(String(50)) + # Migration 099. Not gated on fuel type: a diesel's filters are a scheduled + # item, and an older petrol vehicle's inline filter is a real one too. + fuel_filter_part_number: Mapped[str | None] = mapped_column(String(50)) lug_nut_torque_nm: Mapped[Decimal | None] = mapped_column(Numeric(6, 1)) coolant_type: Mapped[str | None] = mapped_column(String(50)) brake_fluid_type: Mapped[str | None] = mapped_column(String(30)) diff --git a/backend/app/routes/analytics.py b/backend/app/routes/analytics.py index 3cd4e15f..567530e3 100644 --- a/backend/app/routes/analytics.py +++ b/backend/app/routes/analytics.py @@ -58,15 +58,19 @@ SeasonalAnalyticsSummary, ServiceHistoryItem, ServiceTypeCostBreakdown, + TireAnalyticsSummary, VehicleAnalytics, VendorAnalysis, VendorAnalyticsSummary, ) from app.services import analytics_service +from app.services.analytics_service.tires import tire_readiness from app.services.auth import get_vehicle_or_403, require_auth from app.services.def_service import DEFRecordService from app.services.fuel_service import calculate_average_hours_economy +from app.services.odometer_service import latest_odometer_km_and_date from app.services.service_visit_service import service_visit_cost_load_options +from app.services.tire_service import TireService from app.utils.cache import cached from app.utils.logging_utils import sanitize_for_log from app.utils.render_context import render_context_for_request @@ -1385,6 +1389,34 @@ async def get_seasonal_analytics( ) +@router.get("/vehicles/{vin}/tires", response_model=TireAnalyticsSummary) +async def get_tire_analytics( + vin: str, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth), +) -> TireAnalyticsSummary: + """Tire wear and life for one vehicle, plus what is missing to compute it. + + Read-only and computes nothing of its own: the tires come from + `TireService.list_tires`, so this page and the tire card cannot disagree + about a distance or a projection. What analytics adds is the readiness + block, which is the part worth shipping on an instance that has no tire + data yet. + + Retired tires are INCLUDED (B10). Their final figures are the most complete + data the app will ever hold about them, and they are excluded from the + readiness counts rather than from the response. + """ + await get_vehicle_or_403(vin, user, db) + listed = await TireService(db).list_tires(vin, user, include_retired=True) + odometer_km, _ = await latest_odometer_km_and_date(db, vin.upper().strip()) + return TireAnalyticsSummary( + readiness=tire_readiness(listed.tires), + tires=listed.tires, + has_odometer_record=odometer_km is not None, + ) + + @router.get("/vehicles/{vin}/compare", response_model=PeriodComparison) async def compare_periods( vin: str, diff --git a/backend/app/routes/export.py b/backend/app/routes/export.py index 3d8ca375..dfefe6dd 100644 --- a/backend/app/routes/export.py +++ b/backend/app/routes/export.py @@ -526,6 +526,11 @@ async def export_hours_records_csv( async def export_warranties_csv( request: Request, vin: str, + units: str | None = Query( + None, + pattern="^(metric|imperial)$", + description=_UNITS_QUERY_DESCRIPTION, + ), db: AsyncSession = Depends(get_db), current_user: User | None = Depends(require_auth), ): @@ -542,16 +547,18 @@ async def export_warranties_csv( records = result.scalars().all() # Generate CSV + # Every column here maps to a real WarrantyRecord attribute. The previous + # set named five that do not exist (`coverage`, `cost`, `deductible`, + # `max_claims`, `terms`), so this route returned 500 for any vehicle with a + # warranty. `Mileage Limit (km)` is unit-bearing and resolved by build_csv. headers = [ "Provider", "Type", - "Coverage", + "Policy Number", + "Coverage Details", "Start Date", "End Date", - "Cost", - "Deductible", - "Max Claims", - "Terms", + "Mileage Limit (km)", "Notes", ] @@ -561,18 +568,18 @@ async def export_warranties_csv( [ record.provider or "", record.warranty_type or "", - record.coverage or "", + record.policy_number or "", + record.coverage_details or "", record.start_date.isoformat() if record.start_date else "", record.end_date.isoformat() if record.end_date else "", - f"{record.cost:.2f}" if record.cost else "", - f"{record.deductible:.2f}" if record.deductible else "", - record.max_claims or "", - record.terms or "", + # Canonical km. build_csv converts the cell and renames the + # header token together, so the two cannot disagree. + record.mileage_limit_km if record.mileage_limit_km is not None else None, record.notes or "", ] ) - output = generate_csv_stream(headers, rows) + output = build_csv(headers, rows, await resolve_export_units(units, current_user, db)) # Generate filename filename = f"{vehicle.year}_{vehicle.make}_{vehicle.model}_warranties_{datetime.now().strftime('%Y%m%d')}.csv" @@ -612,6 +619,7 @@ async def export_insurance_csv( "Start Date", "End Date", "Premium", + "Premium Frequency", "Deductible", "Coverage Limits", "Notes", @@ -626,7 +634,8 @@ async def export_insurance_csv( record.policy_type or "", record.start_date.isoformat() if record.start_date else "", record.end_date.isoformat() if record.end_date else "", - f"{record.premium:.2f}" if record.premium else "", + f"{record.premium_amount:.2f}" if record.premium_amount else "", + record.premium_frequency or "", f"{record.deductible:.2f}" if record.deductible else "", record.coverage_limits or "", record.notes or "", diff --git a/backend/app/routes/import_data.py b/backend/app/routes/import_data.py index 608d7856..da26d019 100644 --- a/backend/app/routes/import_data.py +++ b/backend/app/routes/import_data.py @@ -73,6 +73,7 @@ FUEL_SPEED, FUEL_TEMPERATURE, FUEL_VOLUME, + MILEAGE_LIMIT_DISTANCE, ODOMETER_DISTANCE, PRICE_PER_VOLUME, READING_DISTANCE, @@ -637,7 +638,12 @@ async def import_odometer_csv( # Create record record = OdometerRecord(vin=vin, date=date, odometer_km=odometer_km, notes=notes) - db.add(record) + # A savepoint per row. Without it the INSERT is only attempted at + # the commit below, which is outside this handler: a CHECK + # violation would escape the route as a 500 and discard every + # valid row in the file along with the bad one. + async with db.begin_nested(): + db.add(record) import_result.add_success() except Exception as e: @@ -743,23 +749,29 @@ async def import_warranties_csv( """Import warranties from CSV file.""" await get_vehicle_or_403(vin, current_user, db, require_write=True) - # Validate and parse CSV + # Validate and parse CSV. Read through the unit machinery rather than a + # bare DictReader: `Mileage Limit` is unit-bearing, so the file's own + # marker decides whether the number is km or miles. A plain reader cannot + # see a tokenised header at all, and would silently store miles as km. csv_data = await validate_csv_upload(file) - csv_reader = csv.DictReader(io.StringIO(csv_data)) + rows, units = _read_csv_with_units(csv_data, (MILEAGE_LIMIT_DISTANCE,)) import_result = ImportResult() - for row_num, row in enumerate(csv_reader, start=2): + for row_num, row in enumerate(rows, start=2): try: provider = row.get("Provider", "").strip() or None warranty_type = row.get("Type", "").strip() or None - coverage = row.get("Coverage", "").strip() or None + # `Coverage` is the pre-v3.3.0 spelling. Both are read so a file + # exported before this release still imports rather than coming + # back 200 with every row blamed on the user's file. + coverage_details = ( + row.get("Coverage Details", "") or row.get("Coverage", "") + ).strip() or None + policy_number = row.get("Policy Number", "").strip() or None start_date = parse_date(row.get("Start Date", "")) end_date = parse_date(row.get("End Date", "")) - cost = parse_decimal(row.get("Cost", "")) - deductible = parse_decimal(row.get("Deductible", "")) - max_claims = parse_int(row.get("Max Claims", "")) - terms = row.get("Terms", "").strip() or None + mileage_limit_km = _canonical_cell(units, row, DISTANCE) notes = row.get("Notes", "").strip() or None # Check for duplicates if requested @@ -780,16 +792,19 @@ async def import_warranties_csv( vin=vin, provider=provider, warranty_type=warranty_type, - coverage=coverage, + policy_number=policy_number, + coverage_details=coverage_details, start_date=start_date, end_date=end_date, - cost=cost, - deductible=deductible, - max_claims=max_claims, - terms=terms, + mileage_limit_km=mileage_limit_km, notes=notes, ) - db.add(record) + # A savepoint per row. Without it the INSERT is only attempted at + # the commit below, which is outside this handler: a CHECK + # violation would escape the route as a 500 and discard every + # valid row in the file along with the bad one. + async with db.begin_nested(): + db.add(record) import_result.add_success() except Exception as e: @@ -827,7 +842,8 @@ async def import_insurance_csv( policy_type = row.get("Type", "").strip() or None start_date = parse_date(row.get("Start Date", "")) end_date = parse_date(row.get("End Date", "")) - premium = parse_decimal(row.get("Premium", "")) + premium_amount = parse_decimal(row.get("Premium", "")) + premium_frequency = row.get("Premium Frequency", "").strip() or None deductible = parse_decimal(row.get("Deductible", "")) coverage_limits = row.get("Coverage Limits", "").strip() or None notes = row.get("Notes", "").strip() or None @@ -852,12 +868,18 @@ async def import_insurance_csv( policy_type=policy_type, start_date=start_date, end_date=end_date, - premium=premium, + premium_amount=premium_amount, + premium_frequency=premium_frequency, deductible=deductible, coverage_limits=coverage_limits, notes=notes, ) - db.add(record) + # A savepoint per row. Without it the INSERT is only attempted at + # the commit below, which is outside this handler: a CHECK + # violation would escape the route as a 500 and discard every + # valid row in the file along with the bad one. + async with db.begin_nested(): + db.add(record) import_result.add_success() except Exception as e: @@ -890,20 +912,26 @@ async def import_tax_csv( for row_num, row in enumerate(csv_reader, start=2): try: - year = parse_int(row.get("Year", "")) + # TaxRecord has `date`, `tax_type`, `amount`, `renewal_date`, + # `notes`. This importer previously read `Year`, `Paid Date`, + # `Due Date` and `Jurisdiction` and constructed with four + # attributes the model does not have, so no tax record has ever + # imported. The export writes `Date` and `Renewal Date`; the two + # halves now share one vocabulary. + record_date = parse_date(row.get("Date", "")) or parse_date(row.get("Paid Date", "")) tax_type = row.get("Type", "").strip() or None amount = parse_decimal(row.get("Amount", "")) - paid_date = parse_date(row.get("Paid Date", "")) - due_date = parse_date(row.get("Due Date", "")) - jurisdiction = row.get("Jurisdiction", "").strip() or None + renewal_date = parse_date(row.get("Renewal Date", "")) or parse_date( + row.get("Due Date", "") + ) notes = row.get("Notes", "").strip() or None # Check for duplicates if requested - if skip_duplicates and year and tax_type: + if skip_duplicates and record_date and tax_type: existing = await db.execute( select(TaxRecord).where( TaxRecord.vin == vin, - TaxRecord.year == year, + TaxRecord.date == record_date, TaxRecord.tax_type == tax_type, ) ) @@ -914,15 +942,18 @@ async def import_tax_csv( # Create record record = TaxRecord( vin=vin, - year=year, + date=record_date, tax_type=tax_type, amount=amount, - paid_date=paid_date, - due_date=due_date, - jurisdiction=jurisdiction, + renewal_date=renewal_date, notes=notes, ) - db.add(record) + # A savepoint per row. Without it the INSERT is only attempted at + # the commit below, which is outside this handler: a CHECK + # violation would escape the route as a 500 and discard every + # valid row in the file along with the bad one. + async with db.begin_nested(): + db.add(record) import_result.add_success() except Exception as e: @@ -970,7 +1001,12 @@ async def import_notes_csv( # Create record record = Note(vin=vin, date=date, title=title, content=content) - db.add(record) + # A savepoint per row. Without it the INSERT is only attempted at + # the commit below, which is outside this handler: a CHECK + # violation would escape the route as a 500 and discard every + # valid row in the file along with the bad one. + async with db.begin_nested(): + db.add(record) import_result.add_success() except Exception as e: diff --git a/backend/app/routes/livelink_admin.py b/backend/app/routes/livelink_admin.py index 1c609326..a4b1c886 100644 --- a/backend/app/routes/livelink_admin.py +++ b/backend/app/routes/livelink_admin.py @@ -104,6 +104,8 @@ async def get_livelink_settings( firmware_check_enabled=await _get_bool_setting(db, "livelink_firmware_check_enabled", True), alert_cooldown_minutes=await service.get_alert_cooldown_minutes(), session_grace_period_seconds=await service.get_session_grace_period_seconds(), + session_gap_minutes=await service.get_session_gap_minutes(), + session_boundary_mode=await service.get_session_boundary_mode(), notify_device_offline=await _get_bool_setting(db, "livelink_notify_device_offline", True), notify_threshold_alerts=await _get_bool_setting( db, "livelink_notify_threshold_alerts", True @@ -161,6 +163,14 @@ async def update_livelink_settings( "livelink_session_grace_period_seconds", str(updates.session_grace_period_seconds), ) + if updates.session_gap_minutes is not None: + await SettingsService.set( + db, "livelink_session_gap_minutes", str(updates.session_gap_minutes) + ) + if updates.session_boundary_mode is not None: + await SettingsService.set( + db, "livelink_session_boundary_mode", updates.session_boundary_mode + ) if updates.notify_device_offline is not None: await SettingsService.set( db, "livelink_notify_device_offline", str(updates.notify_device_offline).lower() @@ -211,6 +221,20 @@ async def regenerate_global_token( # ============================================================================= +async def _device_response(db: AsyncSession, device) -> LiveLinkDeviceResponse: + """One device, with `movement_unreadable` actually answered. + + The field defaults to False on the schema, so a handler that skips this + reports "this device's movement reads fine" about a device nobody asked + about. That is the silent zero this whole feature exists to remove, one + layer up from where it was removed. + """ + unreadable = await LiveLinkService(db).movement_unreadable_device_ids([device]) + return LiveLinkDeviceResponse.model_validate(device).model_copy( + update={"movement_unreadable": device.device_id in unreadable} + ) + + @router.get("/devices", response_model=LiveLinkDeviceListResponse) async def list_devices( db: AsyncSession = Depends(get_db), @@ -224,11 +248,17 @@ async def list_devices( """ service = LiveLinkService(db) devices = await service.list_devices() + unreadable = await service.movement_unreadable_device_ids(devices) online_count = sum(1 for d in devices if d.device_status == "online") return LiveLinkDeviceListResponse( - devices=[LiveLinkDeviceResponse.model_validate(d) for d in devices], + devices=[ + LiveLinkDeviceResponse.model_validate(d).model_copy( + update={"movement_unreadable": d.device_id in unreadable} + ) + for d in devices + ], total=len(devices), online_count=online_count, ) @@ -247,7 +277,7 @@ async def get_device( - Owner of the device's linked vehicle (admin for unlinked devices). """ device = await _get_device_for_owner_or_404(db, device_id, current_user) - return LiveLinkDeviceResponse.model_validate(device) + return await _device_response(db, device) @router.put("/devices/{device_id}", response_model=LiveLinkDeviceResponse) @@ -292,7 +322,7 @@ async def update_device( if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - return LiveLinkDeviceResponse.model_validate(device) + return await _device_response(db, device) @router.delete("/devices/{device_id}", status_code=204) diff --git a/backend/app/routes/livelink_vehicle.py b/backend/app/routes/livelink_vehicle.py index 1b9845a3..561ccb4c 100644 --- a/backend/app/routes/livelink_vehicle.py +++ b/backend/app/routes/livelink_vehicle.py @@ -298,6 +298,9 @@ async def list_vehicle_sessions( offset: int = Query(0, ge=0), start: datetime | None = Query(None, description="Filter sessions starting after this time"), end: datetime | None = Query(None, description="Filter sessions ending before this time"), + include_stationary: bool = Query( + True, description="Include sessions with no evidence the vehicle moved" + ), db: AsyncSession = Depends(get_db), current_user: User = Depends(require_auth), ): @@ -312,6 +315,14 @@ async def list_vehicle_sessions( - **offset**: Pagination offset - **start**: Filter by start time - **end**: Filter by end time + - **include_stationary**: Include sessions in which nothing moved. The + pre-v3.3.0 rule opened a drive whenever the dongle reached the broker, so + a parked vehicle checking in became one. Defaults True, so no existing + caller loses history. Filters on MOVEMENT rather than on which rule cut + the session, because plenty of pre-v3.3.0 sessions are real journeys. + + `stationary_total` is reported either way, so a view that hides them can say + how many it is holding back rather than appear empty for no reason. **Security:** - Requires authentication @@ -327,12 +338,15 @@ async def list_vehicle_sessions( offset=offset, start=start, end=end, + include_stationary=include_stationary, ) - total = await session_service.get_session_count(vin) + total = await session_service.get_session_count(vin, include_stationary=include_stationary) + stationary_total = await session_service.get_stationary_session_count(vin) return DriveSessionListResponse( sessions=[DriveSessionResponse.model_validate(s) for s in sessions], total=total, + stationary_total=stationary_total, ) diff --git a/backend/app/routes/tires.py b/backend/app/routes/tires.py index 93d9473e..bc187104 100644 --- a/backend/app/routes/tires.py +++ b/backend/app/routes/tires.py @@ -9,13 +9,23 @@ from app.models.user import User from app.schemas.tire import ( TireCreate, + TireCreateAndMountRequest, + TireDismountRequest, TireListResponse, + TireMountRequest, TireReadingCreate, TireResponse, + TireRotationRequest, + TireSetCreate, + TireSetListResponse, + TireSetMountRequest, + TireSetResponse, + TireSetUpdate, TireUpdate, ) from app.services.auth import require_auth from app.services.tire_service import TireService +from app.services.tire_set_service import TireSetService logger = logging.getLogger(__name__) @@ -25,22 +35,111 @@ @router.get("/{vin}/tires", response_model=TireListResponse) async def list_tires( vin: str, + include_retired: bool = False, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_auth), ) -> TireListResponse: - """List tires (all positions) for a vehicle with wear projections.""" - return await TireService(db).list_tires(vin, current_user) + """List a vehicle's tires, with distance and wear. + + Retired tires are excluded unless `include_retired` is set: they are + history rather than inventory, and nothing more can be recorded about them. + """ + return await TireService(db).list_tires(vin, current_user, include_retired) @router.post("/{vin}/tires", response_model=TireResponse, status_code=201) -async def upsert_tire( +async def create_tire( vin: str, data: TireCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_auth), ) -> TireResponse: - """Create or replace the tire at a given position.""" - return await TireService(db).upsert_tire(vin, data, current_user) + """Create a tire. It is not mounted until you mount it. + + **Breaking in v3.3.0.** This used to take a `position` and upsert by + `(vin, position)`. It no longer accepts `position` at all, and a payload + carrying one is rejected with 422 rather than silently creating a second, + unmounted tire. Create then mount, or use the create-and-mount endpoint. + """ + return await TireService(db).create_tire(vin, data, current_user) + + +@router.post("/{vin}/tires/rotate", response_model=TireListResponse) +async def rotate_tires( + vin: str, + data: TireRotationRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth), +) -> TireListResponse: + """Move several tires at once. + + All or nothing: if any move is invalid, nothing moves. Returns the whole + tire list, because a rotation changes several of them and returning one + would leave the caller to re-fetch the rest. + """ + return await TireService(db).rotate_tires(vin, data, current_user) + + +@router.post("/{vin}/tires/create-and-mount", response_model=TireResponse, status_code=201) +async def create_and_mount_tire( + vin: str, + data: TireCreateAndMountRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth), +) -> TireResponse: + """Create a tire and mount it in one step. + + Atomic: if the corner is occupied the whole operation fails with 409 and + no tire is created. Doing the two calls by hand and losing the second + leaves an orphan tire the caller did not ask for. + """ + return await TireService(db).create_and_mount(vin, data, current_user) + + +@router.post("/{vin}/tires/{tire_id}/mount", response_model=TireResponse) +async def mount_tire( + vin: str, + tire_id: int, + data: TireMountRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth), +) -> TireResponse: + """Mount a stored tire at a position, opening a mount period. + + 409 if this tire is already mounted, or if another tire holds that corner. + """ + return await TireService(db).mount_tire(vin, tire_id, data, current_user) + + +@router.post("/{vin}/tires/{tire_id}/retire", response_model=TireResponse) +async def retire_tire( + vin: str, + tire_id: int, + data: TireDismountRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth), +) -> TireResponse: + """Retire a tire: take it off the vehicle and keep its whole history. + + This is what replacing a worn tire means. `DELETE` still exists for a tire + entered by mistake, and it destroys every reading and mount period. + """ + return await TireService(db).retire_tire(vin, tire_id, data, current_user) + + +@router.post("/{vin}/tires/{tire_id}/dismount", response_model=TireResponse) +async def dismount_tire( + vin: str, + tire_id: int, + data: TireDismountRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth), +) -> TireResponse: + """Take a tire off the vehicle, closing its open mount period. + + The tire keeps its readings and its history; it simply has no position. + """ + return await TireService(db).dismount_tire(vin, tire_id, data, current_user) @router.put("/{vin}/tires/{tire_id}", response_model=TireResponse) @@ -80,3 +179,71 @@ async def add_tire_reading( ) -> TireResponse: """Append a tread/pressure reading and refresh wear projection + reminders.""" return await TireService(db).add_reading(vin, tire_id, data, current_user) + + +# --- Tire sets ------------------------------------------------------------ +# +# `/{vin}/tire-sets` rather than `/{vin}/tires/sets`: the second would sit under +# the `/{vin}/tires/{tire_id}` pattern and be shadowed by it unless declared +# first, which is a route-ordering trap the rotate endpoint already had to be +# careful about. A separate literal segment cannot be shadowed at all. + + +@router.get("/{vin}/tire-sets", response_model=TireSetListResponse) +async def list_tire_sets( + vin: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth), +) -> TireSetListResponse: + """Every tire set for a vehicle, with its membership.""" + return await TireSetService(db).list_sets(vin, current_user) + + +@router.post("/{vin}/tire-sets", response_model=TireSetResponse, status_code=201) +async def create_tire_set( + vin: str, + data: TireSetCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth), +) -> TireSetResponse: + """Name a new, empty set. Tires join it through `PUT /tires/{id}`.""" + return await TireSetService(db).create_set(vin, data, current_user) + + +@router.put("/{vin}/tire-sets/{set_id}", response_model=TireSetResponse) +async def update_tire_set( + vin: str, + set_id: int, + data: TireSetUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth), +) -> TireSetResponse: + """Rename a set, or change its notes.""" + return await TireSetService(db).update_set(vin, set_id, data, current_user) + + +@router.delete("/{vin}/tire-sets/{set_id}", status_code=204) +async def delete_tire_set( + vin: str, + set_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth), +) -> None: + """Delete a set. Its tires survive, ungrouped.""" + await TireSetService(db).delete_set(vin, set_id, current_user) + + +@router.post("/{vin}/tire-sets/{set_id}/mount", response_model=TireListResponse) +async def mount_tire_set( + vin: str, + set_id: int, + data: TireSetMountRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth), +) -> TireListResponse: + """Fit every tire in a set, each at the corner it was last on. + + Returns the vehicle's whole tire list, because a swap changes the set that + came off as well as the one that went on. + """ + return await TireSetService(db).mount_set(vin, set_id, data, current_user) diff --git a/backend/app/schemas/analytics.py b/backend/app/schemas/analytics.py index 0a93fbca..1cf730c7 100644 --- a/backend/app/schemas/analytics.py +++ b/backend/app/schemas/analytics.py @@ -6,6 +6,8 @@ from pydantic import BaseModel, Field +from app.schemas.tire import TireResponse + class MonthlyCostSummary(BaseModel): """Monthly cost summary.""" @@ -451,3 +453,61 @@ class PeriodComparison(BaseModel): l_per_100km_change_percent: Decimal | None = None model_config = {"from_attributes": True} + + +class TireReadiness(BaseModel): + """How many of a vehicle's live tires can answer each question. + + Retired tires are counted in none of these (B10). The three capabilities + are INDEPENDENT and so are the four prompts: a tire can have a perfectly + good distance and no projection, and telling that owner to add odometers to + their tread readings would be advice about the wrong data. + + The prompts are what the readiness block is for. A page that only said + "0 of 2" would be an apology; these say which number to go and write down. + """ + + #: Non-retired tires on this vehicle. + total: int = 0 + #: Has two or more tread-bearing readings, so a trend line exists. + can_trend: int = 0 + #: `wear_status` carries an actual figure (`projected` or the + #: at-or-below-minimum safety case). + can_project: int = 0 + #: `distance_status` is `complete`. A partial history counts as a prompt, + #: not as an answer, even though it does report its measurable part. + can_report_distance: int = 0 + #: Below `min_tread_mm` today. Surfaced as an action rather than a chart. + under_minimum: int = 0 + + #: Fewer than two tread-bearing readings. One reading is a point. + needs_second_reading: int = 0 + #: Two readings, but one of the newest pair carries no odometer. + needs_reading_odometer: int = 0 + #: `min_tread_mm` is null, so nothing can be projected against it. There is + #: no 2.0 fallback: that is a column default applied at insert. + needs_minimum_tread: int = 0 + #: Distance is blocked on a mount period's odometer bound. Excludes + #: `spare_only` (a state, not a gap) and `odometer_rollback` (bad data, + #: repaired by correcting a number rather than supplying one). + needs_mount_odometer: int = 0 + + +class TireAnalyticsSummary(BaseModel): + """Tire wear and life for one vehicle. + + `tires` are the SAME `TireResponse` objects the tire card renders, computed + once by `TireService`. Analytics deliberately adds no second serialisation + of distance or wear: a copy that can disagree with the card is worse than + no copy, and the tread trend is derivable from each tire's own readings, + which are already on the wire. + """ + + readiness: TireReadiness = Field(default_factory=TireReadiness) + #: Every tire, retired ones included. The retired ones carry `retired_on` + #: and belong in the history blocks only. + tires: list[TireResponse] = Field(default_factory=list) + #: False when the vehicle has no `OdometerRecord` at all, which makes every + #: OPEN mount period unbounded however complete its history is. Its own + #: empty state, explained once rather than per tire. + has_odometer_record: bool = False diff --git a/backend/app/schemas/drive_session.py b/backend/app/schemas/drive_session.py index 2161d82b..19e0ac7f 100644 --- a/backend/app/schemas/drive_session.py +++ b/backend/app/schemas/drive_session.py @@ -24,6 +24,14 @@ class DriveSessionResponse(DriveSessionBase): vin: str device_id: str + #: Which rule cut this session's boundaries. 0 means the pre-v3.3.0 contact + #: rule, which opened a drive whenever the dongle reached the broker, so a + #: parked vehicle checking in became one. Exposed so a row shown under + #: "include earlier drives" can say what it is rather than just look wrong. + boundary_algorithm_version: int = Field( + 0, description="0 = recorded on device contact (pre-v3.3.0), 1 = on movement" + ) + # Odometer data start_odometer: float | None = Field(None, description="Odometer at start (km)") end_odometer: float | None = Field(None, description="Odometer at end (km)") @@ -64,7 +72,12 @@ class DriveSessionListResponse(BaseModel): """Schema for drive session list response.""" sessions: list[DriveSessionResponse] + #: Sessions matching the request's filter, so pagination and the list agree. total: int + #: Sessions with no evidence the vehicle moved, reported whether or not they + #: are included. A filtered list that shows nothing is indistinguishable + #: from a broken one unless it can say how many it is holding back. + stationary_total: int = 0 class DriveSessionDetailResponse(DriveSessionResponse): diff --git a/backend/app/schemas/livelink.py b/backend/app/schemas/livelink.py index 121461d5..3fff0478 100644 --- a/backend/app/schemas/livelink.py +++ b/backend/app/schemas/livelink.py @@ -67,6 +67,22 @@ class LiveLinkDeviceResponse(LiveLinkDeviceBase): ) enabled: bool last_seen: datetime | None + #: When this device last proved the vehicle moved. NULL on a device whose + #: speed and odometer arrive under names nothing here recognises, which + #: records no drives at all -- so the admin page can say which device that + #: is instead of showing an empty session list with no explanation. A silent + #: zero is the failure the whole boundary rework exists to remove. + last_movement_at: datetime | None = Field( + None, description="When this device last reported a recognised movement signal" + ) + #: Computed per response, not stored; see + #: `LiveLinkService.movement_unreadable_device_ids` for the rule and why it + #: is not simply `last_movement_at is None`. Every handler returning this + #: schema must answer it: the False default would otherwise report "reads + #: fine" about a device nobody asked about. + movement_unreadable: bool = Field( + False, description="Device reports telemetry but nothing recognisable as movement" + ) created_at: datetime updated_at: datetime | None @@ -188,6 +204,19 @@ class LiveLinkSettingsResponse(BaseModel): 60, description="Seconds to wait before ending session after ECU offline (0 = disabled)" ) + # Session boundaries. `session_timeout_minutes` above is a CONNECTION-LOSS + # detector; this is the separate question of whether a stop ended the drive. + session_gap_minutes: int = Field( + 15, description="Minutes stationary before a stop counts as a separate drive" + ) + session_boundary_mode: str = Field( + "movement", + description=( + "'movement' decides sessions from speed/odometer/RPM; 'contact' restores " + "the pre-v3.3.0 rule of opening one whenever the device is reachable" + ), + ) + # Notification toggles notify_device_offline: bool = Field(True, description="Notify when device goes offline") notify_threshold_alerts: bool = Field(True, description="Notify on threshold breaches") @@ -206,17 +235,16 @@ class LiveLinkSettingsUpdate(BaseModel): firmware_check_enabled: bool | None = None alert_cooldown_minutes: int | None = Field(None, ge=5, le=120) session_grace_period_seconds: int | None = Field(None, ge=0, le=300) + # Lower bound of 1, not 0: a zero gap closes a session on the first + # stationary sample, so every traffic light becomes a separate trip. + session_gap_minutes: int | None = Field(None, ge=1, le=240) + session_boundary_mode: Literal["movement", "contact"] | None = None notify_device_offline: bool | None = None notify_threshold_alerts: bool | None = None notify_firmware_update: bool | None = None notify_new_device: bool | None = None -# ============================================================================= -# Firmware Schemas -# ============================================================================= - - class FirmwareInfoResponse(BaseModel): """Schema for firmware information response.""" diff --git a/backend/app/schemas/tire.py b/backend/app/schemas/tire.py index 62ba5685..1487ebb7 100644 --- a/backend/app/schemas/tire.py +++ b/backend/app/schemas/tire.py @@ -7,21 +7,40 @@ from decimal import Decimal from typing import Literal -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator TirePosition = Literal["FL", "FR", "RL", "RR", "SPARE"] TIRE_POSITIONS: tuple[str, ...] = ("FL", "FR", "RL", "RR", "SPARE") class TireBase(BaseModel): - """Shared tire fields.""" + """Shared tire fields. + + **`position` is not here, and neither is `installed_date`.** + + `position` left because a tire is no longer identified by a corner (D2c): + it is a thing you own, which is sometimes mounted somewhere. Mounting is a + separate operation with its own conflict semantics, so `POST /api/tires` + no longer upserts by position. + + `installed_date` left because it is now DERIVED from the earliest mount + period (D12). Keeping it writable would need synchronisation on every + period create, edit and delete, and would give the same fact two sources. + + `extra="forbid"` is deliberate and is what makes the break loud (D13). A + stale v3.2 browser tab POSTing a payload with `position` gets a 422 naming + the field. Pydantic's default is to IGNORE unknown fields, which here would + mean silently creating a second, unmounted tire instead of updating the one + at that corner -- a duplicate the user did not ask for and cannot see the + cause of. + """ + + model_config = ConfigDict(extra="forbid") - position: TirePosition brand: str | None = Field(None, max_length=80) model_name: str | None = Field(None, max_length=80) size: str | None = Field(None, max_length=40) dot_code: str | None = Field(None, max_length=20) - installed_date: date_type | None = None tread_depth_mm: Decimal | None = Field(None, ge=0, le=30) pressure_kpa: Decimal | None = Field(None, ge=0, le=1000) min_tread_mm: Decimal | None = Field( @@ -40,17 +59,106 @@ class TireCreate(TireBase): class TireUpdate(BaseModel): - """Partial tire update.""" + """Partial tire update. + + Neither `position` nor `installed_date` is writable here: position changes + through mount/dismount (D14), and `installed_date` is derived (D12). + """ + + model_config = ConfigDict(extra="forbid") brand: str | None = Field(None, max_length=80) model_name: str | None = Field(None, max_length=80) size: str | None = Field(None, max_length=40) dot_code: str | None = Field(None, max_length=20) - installed_date: date_type | None = None tread_depth_mm: Decimal | None = Field(None, ge=0, le=30) pressure_kpa: Decimal | None = Field(None, ge=0, le=1000) min_tread_mm: Decimal | None = Field(None, ge=0, le=10) notes: str | None = None + #: The set this tire belongs to, or null for ungrouped. Writable HERE and + #: nowhere else: `TireCreate` deliberately does not take one, because a set + #: is a label applied to a tire you already own rather than part of its + #: identity. `update_tire` uses `exclude_unset`, so omitting the key leaves + #: membership alone and sending null clears it -- two different intents that + #: a plain optional field would collapse into one. + set_id: int | None = None + + +class TireMountRequest(BaseModel): + """Mount a tire at a position.""" + + model_config = ConfigDict(extra="forbid") + + position: TirePosition + mounted_on: date_type | None = None + mounted_odometer_km: Decimal | None = Field(None, ge=0) + notes: str | None = None + + +class TireDismountRequest(BaseModel): + """Take a tire off the vehicle.""" + + model_config = ConfigDict(extra="forbid") + + dismounted_on: date_type | None = None + dismounted_odometer_km: Decimal | None = Field(None, ge=0) + notes: str | None = None + + +class TireCreateAndMountRequest(TireCreate): + """Create a tire and mount it in one atomic operation. + + Offered because create-then-mount is two calls for the common case, and + the conflict semantics are the MOUNT's: if that corner is occupied the + whole operation fails and no tire is created. A caller that did the two + steps itself and got a conflict on the second would be left with an + orphan tire it did not ask for. + """ + + position: TirePosition + mounted_on: date_type | None = None + mounted_odometer_km: Decimal | None = Field(None, ge=0) + + +class TireRotationMove(BaseModel): + """One tire's destination in a rotation.""" + + model_config = ConfigDict(extra="forbid") + + tire_id: int + position: TirePosition + + +class TireRotationRequest(BaseModel): + """Move several tires at once. + + All or nothing. A partial rotation would leave the vehicle in an + arrangement the user did not ask for and cannot easily read back, which + for something done four tires at a time is worse than a refusal. + """ + + model_config = ConfigDict(extra="forbid") + + moves: list[TireRotationMove] = Field(..., min_length=1) + odometer_km: Decimal | None = Field(None, ge=0) + rotated_on: date_type | None = None + notes: str | None = None + + @model_validator(mode="after") + def _no_duplicate_targets(self) -> TireRotationRequest: + """Two tires cannot be sent to one corner. + + Caught here rather than by the unique index, because the index fires + mid-write and the resulting IntegrityError cannot say which pair of + moves conflicted. + """ + positions = [m.position for m in self.moves] + if len(set(positions)) != len(positions): + raise ValueError("Two tires cannot be rotated to the same position") + tire_ids = [m.tire_id for m in self.moves] + if len(set(tire_ids)) != len(tire_ids): + raise ValueError("A tire cannot be rotated to two positions") + return self class TireReadingCreate(BaseModel): @@ -90,7 +198,9 @@ class TireReadingResponse(BaseModel): id: int tire_id: int vin: str - position: str + # Nullable since 097: a reading can be taken on a stored tire. + position: str | None = None + mount_period_id: int | None = None recorded_at: date_type odometer_km: Decimal | None tread_depth_mm: Decimal | None @@ -101,27 +211,75 @@ class TireReadingResponse(BaseModel): model_config = {"from_attributes": True} +class MountPeriodResponse(BaseModel): + """One interval a tire spent mounted at one position.""" + + id: int + position: str + mounted_on: date_type | None + dismounted_on: date_type | None + mounted_odometer_km: Decimal | None + dismounted_odometer_km: Decimal | None + is_assumed: bool + observed_active_on: date_type | None + notes: str | None + + model_config = {"from_attributes": True} + + class TireResponse(TireBase): - """Tire with optional wear projection.""" + """A tire, with where it is now and what is known about its wear. + + `position` is RE-DECLARED here as nullable rather than inherited: it left + the write schema (D2c) but is still part of every read. Declaring it only + on the base would have made it required on writes; omitting it entirely + would have dropped it from responses. Neither is what a reader wants. + + `installed_date` is DERIVED, not stored (D12): the `mounted_on` of the + earliest period **that has one**. When the earliest period is the migrated + assumed one with a null start, this is null -- NOT the next known remount + date. A plain MIN(mounted_on) would skip the unknown and report a later + date as the installation date, which is worse than reporting nothing. + """ id: int vin: str + position: TirePosition | None = None + set_id: int | None = None + retired_on: date_type | None = None + installed_date: date_type | None = None created_at: datetime updated_at: datetime | None = None - # Estimated km remaining until min_tread_mm based on last two readings. + # Estimated km remaining until min_tread_mm based on the last two readings. projected_km_remaining: Decimal | None = None - # Estimated calendar date of wear-out (null when projection unavailable). + # Estimated calendar date of wear-out. Null even on a successful projection + # when the two readings are same-day, so it is not a proxy for "projected". projected_wear_date: date_type | None = None + # Why a projection is or is not available. See WearStatus. + wear_status: str | None = None + # Distance driven ON THIS TIRE, summed over its mount periods. + distance_km: Decimal | None = None + # The measurable part when the full history is not known, and the date it + # runs from. Non-null for `incomplete`. + known_distance_km: Decimal | None = None + known_distance_since: date_type | None = None + # Why the distance is or is not available. See DistanceStatus. + distance_status: str | None = None + # The periods a user must supply a number for, so the UI can link to the + # exact one instead of saying "record a mount". + blocking_period_ids: list[int] = Field(default_factory=list) below_threshold: bool = False + mount_periods: list[MountPeriodResponse] = Field(default_factory=list) readings: list[TireReadingResponse] = Field(default_factory=list) model_config = {"from_attributes": True} @field_validator("position") @classmethod - def _position_ok(cls, v: str) -> str: - if v not in TIRE_POSITIONS: - raise ValueError(f"position must be one of {TIRE_POSITIONS}") + def _position_ok(cls, v: str | None) -> str | None: + """None is valid: it means the tire is in storage, not mounted.""" + if v is not None and v not in TIRE_POSITIONS: + raise ValueError(f"position must be one of {TIRE_POSITIONS} or null") return v @@ -130,3 +288,69 @@ class TireListResponse(BaseModel): tires: list[TireResponse] total: int + + +class TireSetCreate(BaseModel): + """Name a group of tires. + + No `tire_ids` here. Membership is set from the TIRE side + (`PUT /tires/{id}` with a `set_id`), so there is exactly one writer for it + and no way for the two ends to disagree about who is in what. + """ + + model_config = ConfigDict(extra="forbid") + + name: str = Field(..., min_length=1, max_length=60) + notes: str | None = None + + +class TireSetUpdate(BaseModel): + """Rename a set, or change its notes.""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(None, min_length=1, max_length=60) + notes: str | None = None + + +class TireSetResponse(BaseModel): + """A set, with enough about its members to render it without a second call.""" + + id: int + vin: str + name: str + notes: str | None = None + created_at: datetime + #: Members, retired tires excluded: a retired tire is history rather than + #: inventory, and a set that still counted it would offer to fit a tire the + #: user has thrown away. + tire_ids: list[int] = Field(default_factory=list) + #: How many members are on the vehicle right now. 0 means the whole set is + #: in storage, which is what makes "currently fitted" renderable without + #: the caller joining the tire list itself. + mounted_count: int = 0 + + model_config = {"from_attributes": True} + + +class TireSetListResponse(BaseModel): + """All sets for a vehicle.""" + + sets: list[TireSetResponse] + total: int + + +class TireSetMountRequest(BaseModel): + """Fit every tire in a set, each at the corner it was last on. + + The odometer is a reading of the VEHICLE and applies to the whole swap: it + closes the periods of everything coming off and opens the periods of + everything going on. One number, because that is what the user reads off + the dash once. + """ + + model_config = ConfigDict(extra="forbid") + + odometer_km: Decimal | None = Field(None, ge=0) + mounted_on: date_type | None = None + notes: str | None = None diff --git a/backend/app/schemas/vehicle.py b/backend/app/schemas/vehicle.py index d232eff6..26b26cd6 100644 --- a/backend/app/schemas/vehicle.py +++ b/backend/app/schemas/vehicle.py @@ -121,6 +121,9 @@ class VehicleBase(BaseModel): oil_filter_part_number: str | None = Field( None, description="Oil filter part number", max_length=50 ) + fuel_filter_part_number: str | None = Field( + None, description="Fuel filter part number", max_length=50 + ) lug_nut_torque_nm: Decimal | None = Field( None, description="Wheel lug-nut torque in Newton-meters", ge=0, le=99999.9 ) diff --git a/backend/app/services/analytics_service/tires.py b/backend/app/services/analytics_service/tires.py new file mode 100644 index 00000000..6de1e8c5 --- /dev/null +++ b/backend/app/services/analytics_service/tires.py @@ -0,0 +1,96 @@ +"""Tire readiness: how many tires can answer each question, and what is missing. + +This is the block spec B leads with, and it exists because of a measurement +rather than a preference. On the instance that asked for tire analytics there +were two tires, two readings, and **zero** readings carrying an odometer, so +every analytical block would have rendered empty. A page whose job is to +display tire data has to first help you produce some. + +The three requirements are INDEPENDENT and are counted independently here. +`project_wear` cannot supply them: it short-circuits in a fixed order, so a +tire missing both a minimum tread and its reading odometers reports only +`no_minimum_set`, and a readiness block built from that status would tell the +user about one problem and hide the other. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from app.schemas.analytics import TireReadiness +from app.schemas.tire import TireReadingResponse, TireResponse +from app.services.tire_results import DistanceStatus, WearStatus + +#: Distance statuses that a MOUNT ODOMETER would fix. `spare_only` is a state +#: rather than a gap (the tire has never rolled) and `odometer_rollback` is bad +#: data whose repair is to correct a number, not to supply a missing one. +#: Prompting for a mount odometer on either would be advice about the wrong +#: thing. +_DISTANCE_NEEDS_ODOMETER = frozenset( + { + DistanceStatus.NO_PERIODS, + DistanceStatus.NOTHING_BOUNDED, + DistanceStatus.INCOMPLETE, + } +) + +_WEAR_HAS_FIGURE = frozenset({WearStatus.PROJECTED, WearStatus.AT_OR_BELOW_MINIMUM}) + + +def _tread_bearing(tire: TireResponse) -> list[TireReadingResponse]: + """Readings that carry a tread depth, newest first. + + Same selection `project_wear` makes (`tire_service.py`): sorted by + `recorded_at` descending, then filtered to those with a tread. Kept in step + by a test that seeds a tire missing only its reading odometers and asserts + both this count and `wear_status` agree about it. + """ + return sorted( + [r for r in (tire.readings or []) if r.tread_depth_mm is not None], + key=lambda r: r.recorded_at, + reverse=True, + ) + + +def tire_readiness(tires: Sequence[TireResponse]) -> TireReadiness: + """Count what this vehicle's live tires can and cannot answer. + + Retired tires are excluded from every count (B10). Their distance and wear + are the most complete data the app will ever hold, so they belong in the + history blocks, but telling someone to add an odometer reading to a tire in + a landfill is noise. + + Args: + tires: Every tire for the vehicle, retired ones included. + + Returns: + The counts, over non-retired tires only. + """ + live = [tire for tire in tires if tire.retired_on is None] + + can_trend = 0 + needs_second_reading = 0 + needs_reading_odometer = 0 + for tire in live: + readings = _tread_bearing(tire) + if len(readings) < 2: + needs_second_reading += 1 + continue + can_trend += 1 + # The newest two are the pair `project_wear` differences, so those are + # the two whose odometers matter. A third, older reading without one + # costs nothing. + if readings[0].odometer_km is None or readings[1].odometer_km is None: + needs_reading_odometer += 1 + + return TireReadiness( + total=len(live), + can_trend=can_trend, + can_project=sum(1 for t in live if t.wear_status in _WEAR_HAS_FIGURE), + can_report_distance=sum(1 for t in live if t.distance_status == DistanceStatus.COMPLETE), + under_minimum=sum(1 for t in live if t.below_threshold), + needs_second_reading=needs_second_reading, + needs_reading_odometer=needs_reading_odometer, + needs_minimum_tread=sum(1 for t in live if t.min_tread_mm is None), + needs_mount_odometer=sum(1 for t in live if t.distance_status in _DISTANCE_NEEDS_ODOMETER), + ) diff --git a/backend/app/services/livelink_service.py b/backend/app/services/livelink_service.py index 3a71f09b..c781f6f6 100644 --- a/backend/app/services/livelink_service.py +++ b/backend/app/services/livelink_service.py @@ -5,18 +5,28 @@ import logging import secrets import socket +from collections.abc import Sequence +from datetime import timedelta from urllib.parse import urlsplit from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.models.livelink_device import LiveLinkDevice +from app.models.vehicle_telemetry import VehicleTelemetry from app.services.settings_service import SettingsService from app.utils.datetime_utils import utc_now from app.utils.logging_utils import sanitize_for_log +from app.utils.movement_keys import is_parked_heartbeat_key, is_speed_param_key +from app.utils.odometer_units import is_odometer_param_key logger = logging.getLogger(__name__) +#: How recently a device must have published something for "it reports no +#: readable movement" to be worth saying. A dongle left in a drawer has no +#: movement either, and is not a misconfiguration anyone can act on. +OPERATING_RECENTLY_DAYS = 7 + # Token prefix for easy identification TOKEN_PREFIX = "ll_" TOKEN_LENGTH = 32 # 32 bytes = 256 bits of entropy @@ -196,6 +206,75 @@ async def list_devices(self) -> list[LiveLinkDevice]: ) return list(result.scalars().all()) + async def movement_unreadable_device_ids(self, devices: Sequence[LiveLinkDevice]) -> set[str]: + """Device ids whose movement this codebase cannot read, by vocabulary. + + A device recording no drives is either parked, not driven yet, or + publishing its speed under a name nothing here recognises. Only the last + is a problem, and it is a fact about the device's KEYS, not about time: + + * it is publishing something beyond the parked battery heartbeat, and + * none of what it publishes is a speed or an odometer this codebase + knows. + + RPM does not count as readable, though it is a movement signal. An + engine turning with the vehicle stationary is a remote start or a + warm-up, so RPM opens a PENDING drive and never confirms one + (`session_boundaries`). A device whose RPM is legible but whose speed + arrives under an unrecognised name still records no sessions at all, + which is precisely the cohort this names. + + Deliberately NOT ``last_movement_at IS NULL``, and not that paired with + recent telemetry either. Both have been tried and both named entire + fleets on the first boot after upgrading. The bare column is true for + every device migration 098 touches, by construction. Pairing it with + seven days of telemetry HISTORY is worse for being subtler: the column + can only be written by telemetry arriving AFTER the migration while the + history is almost entirely from before it, so the two halves measure + different time bases and every device driven in the last week but not + since the upgrade comes out flagged. + + Asking about the vocabulary removes time from the question. A device + that has never moved is not misdescribed, because "can this be read" and + "has this moved yet" are different questions and only the first has an + action attached. + + One query per candidate, reading DISTINCT keys off + `uq_telemetry_dedup` (`device_id`, `param_key`, `timestamp`) so it is an + index-only scan returning a device's key vocabulary (tens of rows), not + its telemetry. Candidates are filtered in Python first, from columns + already loaded, so a fleet with nothing to answer costs no SQL at all. + """ + cutoff = utc_now() - timedelta(days=OPERATING_RECENTLY_DAYS) + candidates = [ + device.device_id + for device in devices + # A dongle dormant in a drawer publishes nothing and is not + # misconfigured. An unset `last_seen` still asks: SD-card backfill + # inserts telemetry without going through `store_telemetry`, so it + # is not proof of an unused device. + if device.enabled + and device.vin is not None + and (device.last_seen is None or device.last_seen >= cutoff) + ] + if not candidates: + return set() + + unreadable = set() + for device_id in candidates: + rows = await self.db.execute( + select(VehicleTelemetry.param_key) + .where(VehicleTelemetry.device_id == device_id) + .where(VehicleTelemetry.timestamp >= cutoff) + .distinct() + ) + keys = [key for (key,) in rows.all()] + operating = any(not is_parked_heartbeat_key(key) for key in keys) + readable = any(is_speed_param_key(key) or is_odometer_param_key(key) for key in keys) + if operating and not readable: + unreadable.add(device_id) + return unreadable + # ========================================================================= # SD-Card Backfill Helpers # ========================================================================= @@ -550,6 +629,39 @@ async def get_alert_cooldown_minutes(self) -> int: setting = await SettingsService.get(self.db, "livelink_alert_cooldown_minutes") return int(setting.value) if setting and setting.value else 30 + async def get_session_gap_minutes(self) -> int: + """Minutes stationary-but-connected before a stop becomes a separate drive. + + Its own setting, NOT the session timeout. The two answer different + questions -- "has this device gone quiet?" is not "was that the same + drive?" -- and conflating them means an admin cannot fix trip grouping + without also changing failure detection. + + Five minutes would also be actively wrong for the vehicles the movement + predicate exists to rescue. An ICE vehicle idling at a light keeps RPM + and survives a movement-measured timeout; a stationary EV reports + neither speed nor RPM, so every EV stop over five minutes -- a + drive-through, a school pickup, a charging stop, a drawbridge -- would + split the drive. + + Governs BOTH the live path and SD replay, so a drive that arrived off + the card and a live drive are cut the same way. + """ + setting = await SettingsService.get(self.db, "livelink_session_gap_minutes") + return int(setting.value) if setting and setting.value else 15 + + async def get_session_boundary_mode(self) -> str: + """``'movement'`` (default) or ``'contact'`` (the pre-v3.3.0 rule). + + An escape hatch, not a feature. For a device whose signals nothing + recognises, ``contact`` keeps producing REAL drives rather than phantom + ones -- and it gives an operator a way to bisect a bad upgrade on an + instance where downgrading is not possible. + """ + setting = await SettingsService.get(self.db, "livelink_session_boundary_mode") + value = (setting.value or "").strip().lower() if setting else "" + return value if value in {"movement", "contact"} else "movement" + async def get_session_grace_period_seconds(self) -> int: """Get session grace period in seconds (0 = disabled).""" setting = await SettingsService.get(self.db, "livelink_session_grace_period_seconds") diff --git a/backend/app/services/reminder_service.py b/backend/app/services/reminder_service.py index 74c2cd67..d41196df 100644 --- a/backend/app/services/reminder_service.py +++ b/backend/app/services/reminder_service.py @@ -443,7 +443,13 @@ async def check_due_reminders(db: AsyncSession) -> None: title=f"Reminder Due: {reminder.title}", message=_build_reminder_message(reminder, ctx), ) - reminder.last_notified_at = now + # `last_notified_at` is DateTime with no timezone + # (models/reminder.py:40). PostgreSQL rejects an aware value + # for a naive column with asyncpg DataError; SQLite accepts it + # and strips the offset on the way back out, which is why this + # never showed on a dev instance. `now` itself stays aware + # because the cooldown comparison above needs it. + reminder.last_notified_at = now.replace(tzinfo=None) logger.info( "Sent reminder notification for reminder %s (vin=%s)", reminder.id, diff --git a/backend/app/services/session_boundaries.py b/backend/app/services/session_boundaries.py new file mode 100644 index 00000000..30c59078 --- /dev/null +++ b/backend/app/services/session_boundaries.py @@ -0,0 +1,266 @@ +"""What one batch of telemetry says about whether the vehicle is moving. + +Pure functions over a single reading batch. The stateful half of the boundary +rules -- the two-sample debounce, pending drives, expiry, the two clocks -- lives +in :class:`app.services.session_service.SessionService`, because it needs rows. +Keeping the *meaning* of a batch separate from the *transitions* it triggers is +what lets the meaning be tested without a database. + +A drive session used to open on contact: any sign the dongle could reach the +broker. A parked WiCAN publishes a battery-voltage heartbeat roughly every 95 +minutes, and every Mirage session on 2026-09-01 began within 0.1 seconds of one, +twelve for twelve. 83% of recorded sessions were a heartbeat. + +THREE SIGNALS, NOT TWO, AND RPM IS NOT ONE OF THEM ON ITS OWN +------------------------------------------------------------- +The first design said ``speed > 0 OR rpm > 0``. + +*The floor.* ``> 0`` opens a session on a single noisy 1 km/h sample, and that +sample is effectively unvalidatable: ``validate_rate_of_change`` skips entirely +when the previous reading is older than ``RATE_CHECK_MAX_AGE_SECONDS = 120``, +which is exactly the parked-heartbeat case. The floor is +:data:`MOVEMENT_FLOOR_KMH`, which is the *same* constant idle accounting already +used -- not a new one, or "moving" would have had two contradictory definitions +twelve lines apart. + +*The odometer.* An odometer increase proves movement even when speed arrives +under a name nothing recognises, which is the cohort that would otherwise have +no sessions at all and, worse, be erased by any later pass over history that +requires positive evidence of movement. + +*RPM.* An engine turning with the vehicle stationary is a remote start, a +diagnostic session, a winter warm-up, or the eleven-minute driveway idle that was +credited with 14 km and started this rework. So RPM opens a *pending* drive +(see ``SessionService``), never a session. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timedelta + +from app.utils.movement_keys import is_rpm_param_key, is_speed_param_key +from app.utils.odometer_units import is_odometer_param_key + +#: Speed at or above which the vehicle counts as moving, in km/h. +#: +#: Deliberately imported from ``session_service`` rather than defined here: +#: ``_calculate_driving_insights`` has treated ``< 5 km/h`` as not-moving for +#: idle accounting since the session code was written, and two definitions of +#: "moving" in one subsystem is how a vehicle comes to be simultaneously idle +#: and under way. ``test_session_boundaries.py`` asserts they stay equal. +MOVEMENT_FLOOR_KMH = 5.0 + +#: Which signal opened a pending drive. Stored in +#: ``livelink_devices.pending_source`` (VARCHAR(10)). +#: +#: Only ``rpm`` is ever written, and that is not an oversight: a pending drive +#: is by definition "the engine is on and nothing has moved yet". A sample above +#: the movement floor goes straight to the candidate/confirm path, so there is +#: no state in which speed opens a pending drive. The column is wider than one +#: value because a future third signal would land here, and because a stored +#: enum of one is indistinguishable from a boolean nobody named. +PENDING_SOURCE_RPM = "rpm" + +#: Stamped on every session this algorithm cuts. 0 is the column default and +#: means "pre-098, bounded on contact", so history is not misdescribed -- but a +#: NEW session left at 0 would masquerade as history and be skipped by any +#: future pass over history, which is why each constructor sets this explicitly. +BOUNDARY_ALGORITHM_MOVEMENT = 1 + + +def _numeric(value: object) -> float | None: + """``value`` as a float, or None if it is not a number. + + Telemetry values arrive as ``float | int | str | None``: DTC payloads carry + strings, and a dropped reading is None. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) + + +@dataclass(frozen=True) +class MovementSignals: + """The three movement-bearing quantities in one telemetry batch. + + ``None`` means "this batch did not report it", which is different from zero: + a batch reporting ``SPEED = 0`` says the vehicle is stopped, while a batch + reporting no speed at all says nothing. Collapsing the two is how a + stationary EV -- which reports neither speed nor RPM when parked -- becomes + indistinguishable from a device that has gone quiet. + """ + + speed_kmh: float | None + rpm: float | None + odometer_km: float | None + + @property + def has_any_signal(self) -> bool: + """True if the batch reported any of the three at all. + + Used for the diagnostic in C12: a device producing telemetry across a + window but never a movement signal is logged by name, with the keys it + did send, rather than silently recording zero drives. + """ + return self.speed_kmh is not None or self.rpm is not None or self.odometer_km is not None + + @property + def is_above_floor(self) -> bool: + """True if this batch's speed is at or above the movement floor.""" + return self.speed_kmh is not None and self.speed_kmh >= MOVEMENT_FLOOR_KMH + + @property + def is_engine_on(self) -> bool: + """True if the engine is turning. NOT sufficient to open a session.""" + return self.rpm is not None and self.rpm > 0 + + +def extract_signals(samples: Mapping[str, object]) -> MovementSignals: + """Read the movement signals out of a canonicalised telemetry batch. + + ``samples`` maps canonical param keys to values, as + ``TelemetryService.store_telemetry`` has them after canonicalisation and + odometer normalisation -- so ``odometer_km`` here is already canonical + kilometres and needs no conversion. + + The highest speed in the batch wins when two keys carry it, which happens + across a firmware change that renames a PID. Taking the max is the safe + direction: under-reading loses a real drive, while over-reading at worst + opens a session that a long stop would have opened anyway. + """ + speed: float | None = None + rpm: float | None = None + odometer: float | None = None + + for key, raw in samples.items(): + value = _numeric(raw) + if value is None: + continue + if is_speed_param_key(key): + speed = value if speed is None else max(speed, value) + elif is_rpm_param_key(key): + rpm = value if rpm is None else max(rpm, value) + elif is_odometer_param_key(key): + odometer = value if odometer is None else max(odometer, value) + + return MovementSignals(speed_kmh=speed, rpm=rpm, odometer_km=odometer) + + +@dataclass(frozen=True) +class DriveWindow: + """One drive found in a batch of replayed samples. + + ``started_at`` is the first sample of the contact burst the drive belongs + to, which is what aggregates are computed from -- so it backdates past the + first movement sample to keep the ignition-time readings, the opening + odometer above all. ``movement_started_at`` and ``movement_ended_at`` are + when the vehicle actually moved. + """ + + started_at: datetime + movement_started_at: datetime + movement_ended_at: datetime + + +def group_drives( + samples: Sequence[tuple[datetime, str, float]], + gap_minutes: int, +) -> list[DriveWindow]: + """Split replayed samples into the drives they describe. + + The SD card is the only path for anything driven out of broker range, and + ``bulk_backfill`` had never created a session: it called only a refresh that + selects sessions which already exist and are already closed. So a whole + drive taken away from home was recorded as nothing at all. + + Applies the SAME predicate and the SAME gap as the live path, deliberately. + An earlier design revision scoped the gap threshold to SD replay only, + which meant one journey got two different answers depending on whether it + arrived over MQTT or off an SD card. + + The debounce carries over too: a group needs either two movement timestamps + or a genuine odometer increase. A lone above-floor sample is not a drive, + here for the same reason as live -- except that here the consequence is + worse, because a replay path that manufactured a session per contact burst + would invent thousands of phantom drives out of history, and no later + upgrade undoes that. + + Returns non-overlapping windows in chronological order. Overlap matters + because nothing in the schema forbids it (the session time indexes are + non-unique) and every aggregate is a window scan, so two overlapping + sessions both claim the same samples and both report the same distance. + """ + if not samples or gap_minutes <= 0: + return [] + + window = timedelta(minutes=gap_minutes) + + by_time: dict[datetime, dict[str, float]] = {} + for stamp, key, value in samples: + at = stamp.replace(tzinfo=None) if stamp.tzinfo is not None else stamp + by_time.setdefault(at, {})[key] = value + stamps = sorted(by_time) + + #: Movement timestamps, and which of them were proven by the odometer. + movement: list[datetime] = [] + odometer_proven: set[datetime] = set() + highest_odometer: float | None = None + + for at in stamps: + signals = extract_signals(by_time[at]) + moved = signals.is_above_floor + if signals.odometer_km is not None: + if highest_odometer is not None and signals.odometer_km > highest_odometer: + moved = True + odometer_proven.add(at) + # Track the highest seen, not the latest: SD rows can arrive out of + # order, and a lower reading is a replay artefact rather than a + # vehicle driving backwards. + highest_odometer = ( + signals.odometer_km + if highest_odometer is None + else max(highest_odometer, signals.odometer_km) + ) + if moved: + movement.append(at) + + if not movement: + return [] + + groups: list[list[datetime]] = [[movement[0]]] + for at in movement[1:]: + if at - groups[-1][-1] <= window: + groups[-1].append(at) + else: + groups.append([at]) + + drives: list[DriveWindow] = [] + for group in groups: + if len(group) < 2 and not any(at in odometer_proven for at in group): + continue + + first_movement = group[0] + # Walk back through the contact burst: keep absorbing earlier samples + # while each step is inside one gap window. A larger step means the + # device was silent, so those readings belong to a different burst. + burst_start = first_movement + index = stamps.index(first_movement) + while index > 0 and burst_start - stamps[index - 1] <= window: + index -= 1 + burst_start = stamps[index] + + # Never reach back into a drive already accounted for. + if drives and burst_start < drives[-1].movement_ended_at: + burst_start = drives[-1].movement_ended_at + + drives.append( + DriveWindow( + started_at=burst_start, + movement_started_at=max(first_movement, burst_start), + movement_ended_at=group[-1], + ) + ) + + return drives diff --git a/backend/app/services/session_service.py b/backend/app/services/session_service.py index 789968e8..3294c079 100644 --- a/backend/app/services/session_service.py +++ b/backend/app/services/session_service.py @@ -1,25 +1,122 @@ """Session service for drive session detection and management.""" import logging +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta +from decimal import Decimal -from sqlalchemy import func, select +from sqlalchemy import func, or_, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.models.drive_session import DriveSession from app.models.livelink_device import LiveLinkDevice from app.models.vehicle_telemetry import VehicleTelemetry +from app.services.session_boundaries import ( + BOUNDARY_ALGORITHM_MOVEMENT, + PENDING_SOURCE_RPM, + MovementSignals, + extract_signals, +) from app.utils.datetime_utils import utc_now +from app.utils.distance_counters import ( + TravelledSpan, + is_distance_source_param_key, + measure_travelled, + select_distance_source, +) +from app.utils.movement_keys import ( + is_parked_heartbeat_key, + rpm_param_key_candidates, + speed_param_key_candidates, +) from app.utils.odometer_units import is_odometer_param_key logger = logging.getLogger(__name__) -# Possible OBD2 parameter names for vehicle speed (different WiCAN firmware/configs) -SPEED_PARAM_KEYS = ["SPEED", "0D-VehicleSpeed", "0D-VEHICLESPEED"] +# Every spelling of speed and RPM, for the aggregate reader's SQL `IN` lists. +# Derived from `app.utils.movement_keys` rather than written here, so the keys +# that can OPEN a session and the keys the aggregates can READ are one set. They +# were two: `SPEED_PARAM_KEYS` was a hand-written module constant and the RPM +# list was inline in `_calculate_session_aggregates` twelve lines below it, +# neither aware of the other. +SPEED_PARAM_KEYS = speed_param_key_candidates() +RPM_PARAM_KEYS = rpm_param_key_candidates() + +#: Below this speed the vehicle is not moving, in km/h. Hoisted out of +#: `_calculate_driving_insights`, where it was a local, so the movement +#: predicate can share it: "moving" must mean one thing in this subsystem, and +#: a session that opened at 1 km/h while idle accounting called the same sample +#: stationary is a contradiction the code cannot resolve. +IDLE_THRESHOLD_KMH = 5.0 + +#: Every column `refresh_aggregates` derives from a session's window. Listed so +#: `clear_first` can null them all; a column added to the recompute steps but +#: not here would survive a rebound as a stale figure from the wider window. +_DERIVED_SESSION_COLUMNS = ( + "start_odometer", + "end_odometer", + "distance_km", + "avg_speed", + "max_speed", + "avg_rpm", + "max_rpm", + "avg_coolant_temp", + "max_coolant_temp", + "avg_throttle", + "max_throttle", + "avg_fuel_level", + "idle_seconds", + "harsh_accel_count", + "harsh_brake_count", +) + +#: Devices already named by `_warn_if_no_movement_signal_ever`, this process. +#: See that method for why this is process-local rather than a column. +_NO_MOVEMENT_WARNED: set[str] = set() + + +def _moved_predicate(): + """SQL for "this session has evidence the vehicle moved". + + Distance above zero, or a top speed at or above the movement floor. NULL is + not evidence, so ``coalesce`` reads it as stationary: a session whose + telemetry has been pruned cannot prove it was a drive, and showing every + unprovable row defeats the filter entirely. On the data this was measured + against, only 5 of 2,816 speed-less sessions carry any distance at all. + + Uses :data:`IDLE_THRESHOLD_KMH` rather than a literal so the list hides + exactly what the boundary predicate calls stationary. Two definitions of + "moving" in one subsystem is how a vehicle comes to be simultaneously idle + and under way. + """ + return or_( + func.coalesce(DriveSession.distance_km, 0) > 0, + func.coalesce(DriveSession.max_speed, 0) >= IDLE_THRESHOLD_KMH, + ) class SessionService: - """Service for drive session detection and aggregation.""" + """Everything that decides what a drive session IS, and what it says. + + Four responsibilities, in the order they appear below: + + 1. **Session detection.** `handle_ecu_status_change` and the Torque + constructor. Note that the ECU-online branch no longer opens a session + in the default mode -- see `observe_telemetry`. + 2. **Movement-based boundaries.** The state machine: `observe_telemetry` is + its input edge, and the device row holds its state so the MQTT + subscriber, the HTTPS route and the scheduler all see the same thing. + 3. **Aggregates.** `refresh_aggregates` is the single derivation of a + session's numbers; `end_session`, late-arriving telemetry, an SD-card + pull and the repair tools all go through it, so a session summarised on + close and one recomputed months later cannot disagree. + 4. **Timeouts and queries.** `check_session_timeouts` runs the two clocks. + + This file is long. The natural cut is section 3, which is self-contained and + orthogonal to the boundary rules; it was left in place deliberately during + the v3.3.0 boundary rework rather than moved in the same change. + """ def __init__(self, db: AsyncSession): """Initialize with database session.""" @@ -50,9 +147,24 @@ async def handle_ecu_status_change( old_status = device.ecu_status or "unknown" - # ECU went online -> start new session + # ECU online marks the device online and NOTHING MORE. + # + # This is the single change that covers all three live ingest sites -- + # MQTT `can/rx`, MQTT `can/status`, and the HTTPS status block -- which + # is why it belongs here rather than at any one caller. An earlier + # revision of the design fixed only `mqtt_subscriber`'s telemetry- + # inferred path, and the comment above that path says it "handles WiCAN + # devices that don't send explicit can/status messages": by the code's + # own account the FALLBACK. An instance whose dongle sends status + # messages, or any instance on HTTPS ingest, would have kept 100% of its + # phantom sessions while the changelog claimed they were fixed. + # + # `contact` mode restores the old behaviour verbatim, for a device whose + # movement signals nothing recognises. if old_status != "online" and new_ecu_status == "online": - return await self.start_session(device, timestamp) + if await self._boundary_mode() == "contact": + return await self.start_session(device, timestamp) + return None # ECU went offline -> end current session if old_status == "online" and new_ecu_status == "offline": @@ -142,9 +254,16 @@ async def start_session( self.db.add(session) await self.db.flush() - # Update device with current session + # Update device with current session. + # + # `ecu_status` is deliberately NOT written here. Once a session is no + # longer a proxy for ECU state, a movement timeout would mark a device + # whose ECU is awake as offline -- and that is not cosmetic: + # `device_command_service.py` refuses any `requires_ecu` command when + # `ecu_status != "online"`, so the whole remote-command surface would go + # dead after every drive. `routes/torque.py` already carries a + # workaround comment for exactly this coupling. device.current_session_id = session.id - device.ecu_status = "online" logger.info( "Started drive session %d for vehicle %s (device %s)", @@ -158,12 +277,22 @@ async def end_session( self, device: LiveLinkDevice, timestamp: datetime, + *, + retain_pointer: bool = False, ) -> DriveSession | None: """End the current drive session and calculate aggregates. Args: device: The device ending the session timestamp: Session end time + retain_pointer: Keep ``current_session_id`` pointing at the closed + session, putting the device in the ``awaiting`` state so + movement returning inside the drive gap REOPENS this session + instead of creating a second one. Set only by the contact-loss + clock. Without it, movement then six minutes of silence then + movement produces two live sessions and one replayed session, + and the claim that a drive is cut the same way whichever path it + arrived by is aspirational. Returns: The ended DriveSession, or None if no active session @@ -198,9 +327,11 @@ async def end_session( # and one recomputed months later cannot disagree about how it was made. await self.refresh_aggregates(session) - # Clear device's current session - device.current_session_id = None - device.ecu_status = "offline" + # Clear device's current session unless it is being retained for the + # reopen window. `ecu_status` is deliberately not written -- see + # `start_session`. + if not retain_pointer: + device.current_session_id = None logger.info( "Ended drive session %d for vehicle %s (duration: %d seconds)", @@ -210,6 +341,552 @@ async def end_session( ) return session + # ========================================================================= + # Movement-based session boundaries + # ========================================================================= + + async def _gap_minutes(self) -> int: + from app.services.livelink_service import LiveLinkService # local: avoids a cycle + + return await LiveLinkService(self.db).get_session_gap_minutes() + + async def _boundary_mode(self) -> str: + from app.services.livelink_service import LiveLinkService # local: avoids a cycle + + return await LiveLinkService(self.db).get_session_boundary_mode() + + @staticmethod + def _naive(value: datetime) -> datetime: + """Naive UTC, matching every stored timestamp in this subsystem.""" + return value.replace(tzinfo=None) if value.tzinfo is not None else value + + @staticmethod + def _clear_movement_state(device: LiveLinkDevice) -> None: + """Reset all four pending fields together. + + They are one envelope, not four independent flags: `pending_since` says + a warm-up is under way, `pending_source` says which signal opened it, + and the candidate/baseline pair is the evidence window. Clearing a + subset leaves a half-state no transition in the machine describes. + """ + device.pending_since = None + device.pending_source = None + device.movement_candidate_at = None + device.movement_baseline_km = None + + async def observe_telemetry( + self, + device: LiveLinkDevice, + samples: Mapping[str, object], + sample_at: datetime, + *, + live: bool = True, + ) -> DriveSession | None: + """Decide what one telemetry batch means for this device's session. + + The input edge of the state machine. Called once per ingested payload + from the live paths (MQTT and HTTPS, via `TelemetryService`), and never + from the Torque path -- Torque supplies an authoritative session id from + the phone, so a movement predicate has nothing to add there and would + only overrule a better source. + + ``sample_at`` is the SAMPLE time, not the receipt time. ``live=False`` + marks a replay: it may open and extend sessions, but must not write + ``last_movement_at``, because that field anchors every live timeout and + an HTTPS payload carrying an old or future timestamp would drag a live + session hours away from where it belongs. + + Returns the open session, if there now is one. + """ + if not device.vin or not device.enabled: + return None + if await self._boundary_mode() == "contact": + return None + + sample_at = self._naive(sample_at) + gap = await self._gap_minutes() + window = timedelta(minutes=gap) + signals = extract_signals(samples) + + session = await self._live_session(device) + + # Expire a stale evidence window BEFORE evaluating this batch. Without a + # bound, an engine-on at 08:00 and a movement sample at 17:00 are still + # "consecutive", and the session backdates nine hours of parked + # telemetry into a drive. Two samples separated by a disconnect are not + # consecutive in any sense that matters. + if session is None: + if device.pending_since is not None and ( + sample_at - self._naive(device.pending_since) > window + ): + self._clear_movement_state(device) + elif device.movement_candidate_at is not None and ( + sample_at - self._naive(device.movement_candidate_at) > window + ): + device.movement_candidate_at = None + device.movement_baseline_km = None + + confirmed = self._confirm_movement(device, signals, sample_at, session is not None) + + if confirmed: + return await self._promote_to_driving(device, signals, sample_at, gap, live=live) + + if session is not None: + # `driving` -> `stopped`: connected, moved before, not moving now. + # Nothing to do; the session stays open and the drive-gap clock in + # `check_session_timeouts` decides when the stop becomes two trips. + return session + + if not signals.has_any_signal: + self._warn_if_no_movement_signal_ever(device, samples) + + if signals.is_engine_on and device.pending_since is None: + # `idle` -> `pending`. Engine turning with the vehicle stationary is + # a remote start, a diagnostic session, a winter warm-up, or the + # eleven-minute driveway idle that was credited with 14 km. It + # buffers the burst so a drive that follows keeps its warm-up + # samples, and is discarded outright if no movement follows. + device.pending_since = sample_at + device.pending_source = PENDING_SOURCE_RPM + if signals.odometer_km is not None and device.movement_baseline_km is None: + device.movement_baseline_km = Decimal(str(signals.odometer_km)) + device.movement_candidate_at = sample_at + + return None + + def _warn_if_no_movement_signal_ever( + self, device: LiveLinkDevice, samples: Mapping[str, object] + ) -> None: + """Name a device whose movement this code cannot see. + + A silent zero is the failure mode this entire change exists to + eliminate, so reintroducing one for the cohort the movement predicate + cannot read would be absurd. "No sessions, cause unknown" is not + something an operator can act on; "this device publishes + CUSTOM_ROAD_SPEED and nothing recognises it" is -- either a param alias + is missing, or the instance wants `livelink_session_boundary_mode = + contact`. + + Fires only for a device that is plainly OPERATING -- publishing engine + telemetry -- while reporting nothing recognisable as speed, RPM or an + odometer. A parked vehicle publishing only its battery heartbeat should + produce no sessions, and flagging that would make the warning + meaningless on every instance. + + Logged once per device per process, via a module-level set. Deliberately + not a column: a device sends a payload every few seconds, so logging per + payload would bury the diagnostic it exists to surface, while persisting + the fact would need a migration to say something the log says well + enough. Resetting on restart is a feature -- it re-reports a problem + that is still present. + """ + if device.last_movement_at is not None: + return + if device.device_id in _NO_MOVEMENT_WARNED: + return + operating_keys = sorted(key for key in samples if not is_parked_heartbeat_key(key)) + if not operating_keys: + return + _NO_MOVEMENT_WARNED.add(device.device_id) + logger.warning( + "Device %s reports engine telemetry but no recognised movement signal; " + "it will record no drive sessions. Keys seen: %s. Either a parameter " + "alias is missing from app/utils/movement_keys.py, or set " + "livelink_session_boundary_mode=contact for this instance.", + device.device_id, + ", ".join(operating_keys), + ) + + def _confirm_movement( + self, + device: LiveLinkDevice, + signals: MovementSignals, + sample_at: datetime, + session_is_open: bool, + ) -> bool: + """Does this batch prove the vehicle moved? Records evidence if not yet. + + Three signals, per C2. Speed needs TWO consecutive above-floor samples, + because a single one is effectively unvalidatable: + `validate_rate_of_change` skips entirely when the previous reading is + older than `RATE_CHECK_MAX_AGE_SECONDS = 120`, which is exactly the + parked-heartbeat case. An odometer increase across the same window is + the signal that covers a device whose speed arrives under a name nothing + recognises. RPM proves only that the engine is turning. + + Once a session is open the debounce is spent: the vehicle has already + been proven to move, so one above-floor sample extends the drive. + + ``movement_candidate_at`` anchors the evidence window for BOTH signals. + One consequence is worth stating rather than discovering: an above-floor + sample, a below-floor sample, and another above-floor sample inside one + gap window will confirm, even though they are not literally consecutive. + That is accepted -- the debounce exists to suppress a SINGLE spike, and + the parked heartbeat this whole change is about carries no speed key at + all, so it never sets a candidate in the first place. + """ + if session_is_open: + if signals.is_above_floor: + return True + if signals.odometer_km is not None and device.movement_baseline_km is not None: + return signals.odometer_km > float(device.movement_baseline_km) + return False + + if signals.is_above_floor: + if device.movement_candidate_at is not None: + return True + device.movement_candidate_at = sample_at + if signals.odometer_km is not None: + device.movement_baseline_km = Decimal(str(signals.odometer_km)) + return False + + if signals.odometer_km is not None: + if device.movement_baseline_km is not None: + if signals.odometer_km > float(device.movement_baseline_km): + return True + # A parked vehicle republishes the same odometer on every + # heartbeat, and a REPLAY can report a lower one. Neither is + # movement, and neither should advance the baseline past what + # has actually been observed. + return False + device.movement_baseline_km = Decimal(str(signals.odometer_km)) + if device.movement_candidate_at is None: + device.movement_candidate_at = sample_at + + return False + + async def _live_session(self, device: LiveLinkDevice) -> DriveSession | None: + """The device's OPEN session, or None. + + Distinct from `get_current_session`, which returns whatever the pointer + names -- and in the `awaiting` state the pointer deliberately names a + CLOSED session. + """ + if not device.current_session_id: + return None + session = ( + await self.db.execute( + select(DriveSession).where(DriveSession.id == device.current_session_id) + ) + ).scalar_one_or_none() + if session is None or session.ended_at is not None: + return None + return session + + async def _promote_to_driving( + self, + device: LiveLinkDevice, + signals: MovementSignals, + sample_at: datetime, + gap: int, + *, + live: bool, + ) -> DriveSession: + """Movement is confirmed: open, reopen or extend a session.""" + session = await self._live_session(device) + + if session is None: + session = await self._reopen_awaiting(device, sample_at, gap) + if session is None: + session = await self._open_session_for_movement(device, sample_at, gap) + + if session.movement_started_at is None: + session.movement_started_at = sample_at + session.movement_ended_at = sample_at + if signals.odometer_km is not None: + device.movement_baseline_km = Decimal(str(signals.odometer_km)) + if live: + device.last_movement_at = sample_at + + device.pending_since = None + device.pending_source = None + device.movement_candidate_at = None + return session + + async def _reopen_awaiting( + self, device: LiveLinkDevice, sample_at: datetime, gap: int + ) -> DriveSession | None: + """Reopen the session the contact-loss clock closed, if still in reach. + + The `awaiting` state exists so live and replay agree. The contact + timeout still CLOSES promptly -- a device that never returns must not be + left open -- but the session stays reopenable until the drive gap + expires, so a six-minute silence in the middle of one drive does not + become two. + """ + if not device.current_session_id: + return None + retained = ( + await self.db.execute( + select(DriveSession).where(DriveSession.id == device.current_session_id) + ) + ).scalar_one_or_none() + if retained is None or retained.ended_at is None: + device.current_session_id = None if retained is None else device.current_session_id + return None + + anchor = self._naive(retained.movement_ended_at or retained.ended_at) + if sample_at - anchor > timedelta(minutes=gap): + device.current_session_id = None + return None + + retained.ended_at = None + retained.duration_seconds = None + logger.info( + "Reopened drive session %d for device %s (movement returned within the %d-minute gap)", + retained.id, + device.device_id, + gap, + ) + return retained + + async def _open_session_for_movement( + self, device: LiveLinkDevice, sample_at: datetime, gap: int + ) -> DriveSession: + """Open a session whose window keeps the whole opening burst. + + ``started_at`` backdates to the earliest evidence of this drive -- the + engine-on that opened the pending state, or the first above-floor sample + -- NOT to the sample that confirmed movement. Aggregates are strictly + window-bounded, so setting it to the confirming sample silently discards + warm-up coolant, initial fuel level and, critically, the OPENING + ODOMETER READING. `_calculate_session_distance` then finds exactly one + odometer sample in the window and assigns unconditionally, writing + ``start_odometer == end_odometer`` and ``distance_km = 0.0``: a + confident zero, not a blank. + + The tail is trimmed rather than kept, which reads as inconsistent and is + deliberate. The opening burst carries real readings; the closing tail + carries only parked heartbeats, and stamping ``ended_at`` at the last + contact pads every drive by up to one heartbeat interval (95 minutes, + measured) and drags ``avg_speed`` toward zero. See + `check_session_timeouts`. + + ``started_at`` is also clamped past the previous session's close. C5's + whole-burst rule applies to the OPENING burst of a drive; it cannot + apply to a burst already consumed by a previous session, because every + aggregate is a window scan and two overlapping sessions both claim the + same samples and both report the same distance. + """ + candidates = [ + self._naive(value) + for value in (device.pending_since, device.movement_candidate_at) + if value is not None + ] + started_at = min([*candidates, sample_at]) + movement_started_at = ( + self._naive(device.movement_candidate_at) + if device.movement_candidate_at is not None + else sample_at + ) + + previous_end = ( + await self.db.execute( + select(func.max(DriveSession.ended_at)) + .where(DriveSession.device_id == device.device_id) + .where(DriveSession.ended_at.is_not(None)) + ) + ).scalar() + if previous_end is not None: + previous_end = self._naive(previous_end) + if started_at < previous_end: + started_at = previous_end + if movement_started_at < started_at: + movement_started_at = started_at + + # Take a row lock, then RE-READ the pointer under it. + # + # The re-read is the whole point, and leaving it out was measured to + # make the lock useless: MQTT and HTTPS ingest genuinely race, both read + # a NULL `current_session_id`, and both reach here. The loser blocks on + # the lock, but its identity-mapped `device` still holds the stale NULL, + # so it creates anyway and takes an IntegrityError from + # `uq_drive_sessions_open_per_device`. The index protects the DATA + # either way -- one open session per device -- but the losing payload + # still fails, which for a WiCAN means a dropped reading and a retry. + # + # With the re-read the loser adopts the winner's session and both + # payloads succeed. `test_session_concurrency.py` asserts that neither + # racer raises, which is the only way to tell this apart from the + # version that merely looked correct. + # + # No-op on SQLite, whose single writer serialises anyway. + if self.db.bind is not None and self.db.bind.dialect.name == "postgresql": + claimed = ( + await self.db.execute( + select(LiveLinkDevice.current_session_id) + .where(LiveLinkDevice.id == device.id) + .with_for_update() + ) + ).scalar() + if claimed is not None and claimed != device.current_session_id: + await self.db.refresh(device) + adopted = await self._live_session(device) + if adopted is not None: + logger.info( + "Adopted session %d for device %s: another ingest path opened " + "it while this one waited on the device lock", + adopted.id, + device.device_id, + ) + return adopted + + session = DriveSession( + vin=device.vin, + device_id=device.device_id, + started_at=started_at, + movement_started_at=movement_started_at, + movement_ended_at=sample_at, + start_odometer=await self._get_current_odometer(device.vin), + boundary_algorithm_version=BOUNDARY_ALGORITHM_MOVEMENT, + effective_gap_minutes=gap, + ) + + # Insert inside a SAVEPOINT so losing the race is recoverable. + # + # The row lock above closes the window on PostgreSQL, but MyGarage runs + # SQLite in production and there is no `FOR UPDATE` there -- two + # concurrent ingest transactions both insert, and one takes a + # `UNIQUE constraint failed` from `uq_drive_sessions_open_per_device`. + # Without the savepoint that error would poison the whole ingest + # transaction, so the payload's telemetry would be lost along with the + # session it lost the race for. + # + # Adopting the winner's session is the correct outcome, not a fallback: + # both payloads describe the same drive. + try: + async with self.db.begin_nested(): + self.db.add(session) + await self.db.flush() + except IntegrityError: + adopted = await self._open_session_for_device(device.device_id) + if adopted is None: + raise + device.current_session_id = adopted.id + logger.info( + "Adopted session %d for device %s: another ingest path opened it first", + adopted.id, + device.device_id, + ) + return adopted + + device.current_session_id = session.id + + logger.info( + "Opened drive session %d for %s (device %s) on confirmed movement at %s", + session.id, + device.vin, + device.device_id, + sample_at, + ) + return session + + async def _open_session_for_device(self, device_id: str) -> DriveSession | None: + """The device's open session, read from the DATABASE not the pointer. + + Distinct from `_live_session`, which follows `device.current_session_id` + -- and in the race that field is exactly what is stale. + """ + return ( + await self.db.execute( + select(DriveSession) + .where(DriveSession.device_id == device_id) + .where(DriveSession.ended_at.is_(None)) + ) + ).scalar_one_or_none() + + async def begin_provisional_offline( + self, device: LiveLinkDevice, now: datetime | None = None + ) -> None: + """Record an explicit ECU-offline as PROVISIONAL, changing nothing else. + + Pending state is deliberately NOT cleared here. Taken separately, + "clear pending on explicit offline" and "treat offline as provisional + for 60 seconds" mean a brief WiFi drop discards the warm-up and + opening-odometer samples the pending state exists to preserve. It clears + when the offline FINALIZES. + """ + device.pending_offline_at = self._naive(now or utc_now()) + + async def finalize_offline( + self, device: LiveLinkDevice, now: datetime | None = None + ) -> DriveSession | None: + """Close the session on a finalized ECU-offline, directly. + + Not by looking for an online-to-offline transition. The ingest routes + persist ``ecu_status='offline'`` the moment it arrives, so by the time + the grace period expires `handle_ecu_status_change` sees offline -> + offline, no-ops, and leaves the session to a contact timeout anchored on + a ``last_seen`` that the finalizer itself had advanced. The pre-existing + tests mock `handle_ecu_offline` and assert only that it was called, so + they pass with this broken. + + This never touches ``last_seen``: there was no contact, and fabricating + one corrupts every timeout that reads it. + """ + now = self._naive(now or utc_now()) + session = await self._live_session(device) + closed = None + if session is not None: + end_at = self._naive( + session.movement_ended_at or session.movement_started_at or session.started_at + ) + closed = await self.end_session(device, min(end_at, now)) + device.pending_offline_at = None + self._clear_movement_state(device) + return closed + + async def expire_stale_movement_state( + self, gap_minutes: int | None = None, now: datetime | None = None + ) -> int: + """Discard pending drives and finalize `awaiting` closures past the gap. + + Two housekeeping jobs the live path cannot do, because both are defined + by the ABSENCE of a payload: + + - a pending drive older than the gap is discarded, and no session was + ever created for it. A warm-up that went nowhere leaves no trace. + - an `awaiting` session past the gap can no longer be reopened, so the + pointer is cleared and the closure becomes final. + + Returns the number of device rows changed. + """ + now = self._naive(now or utc_now()) + if gap_minutes is None: + gap_minutes = await self._gap_minutes() + window = timedelta(minutes=gap_minutes) + changed = 0 + + stale_pending = ( + ( + await self.db.execute( + select(LiveLinkDevice) + .where(LiveLinkDevice.pending_since.is_not(None)) + .where(LiveLinkDevice.pending_since < now - window) + ) + ) + .scalars() + .all() + ) + for device in stale_pending: + self._clear_movement_state(device) + changed += 1 + + awaiting = ( + await self.db.execute( + select(LiveLinkDevice, DriveSession) + .join(DriveSession, DriveSession.id == LiveLinkDevice.current_session_id) + .where(DriveSession.ended_at.is_not(None)) + ) + ).all() + for device, session in awaiting: + anchor = self._naive(session.movement_ended_at or session.ended_at) + if now - anchor > window: + device.current_session_id = None + changed += 1 + + return changed + async def resolve_torque_session( self, device: LiveLinkDevice, @@ -275,7 +952,7 @@ async def resolve_torque_session( # open the new one and advance the pointer. if device.current_session_id: await self.end_session( - device, utc_now().replace(tzinfo=None) + device, utc_now() ) # server-now end >= prior start (R2-H1, R2-H2) # Deliberately do NOT set start_odometer here. _get_current_odometer() is VIN-scoped, # not device-scoped, so on a vehicle with BOTH a WiCAN dongle and a Torque source it @@ -318,7 +995,7 @@ async def _get_current_odometer(self, vin: str) -> float | None: return float(value) return None - async def refresh_aggregates(self, session: DriveSession) -> None: + async def refresh_aggregates(self, session: DriveSession, *, clear_first: bool = False) -> None: """Recompute a closed session's aggregates from the telemetry now on record. A WiCAN buffers readings while off home WiFi and replays them with their @@ -330,7 +1007,25 @@ async def refresh_aggregates(self, session: DriveSession) -> None: close, `TelemetryService` calls it when a reading or an SD-card pull lands inside a closed session's window, and `tools/ recompute_session_aggregates.py` calls it to repair history. + + ``clear_first`` nulls every derived column before recomputing, and is + for callers that have NARROWED the window. The recompute steps assign + only when they find samples and never clear, which is right for the + scheduled refresh -- telemetry is pruned on a retention schedule while + sessions are kept forever, so an old session's window is legitimately + empty and blanking it would erase the only record of that drive. It is + exactly wrong after a rebound: a session cut down from 95 minutes of + parked heartbeats to the four the vehicle moved would keep the + ``avg_speed`` the wide window produced. + + Two callers wanting opposite things is why this is a parameter. It + defaults to False because a default of True would blank every pruned + session on the next scheduler tick -- destroying data rather than + misreporting it. """ + if clear_first: + for column in _DERIVED_SESSION_COLUMNS: + setattr(session, column, None) await self._calculate_session_distance(session) await self._calculate_session_aggregates(session) await self._calculate_driving_insights(session) @@ -355,23 +1050,30 @@ async def _calculate_session_distance(self, session: DriveSession) -> None: matching `_calculate_session_aggregates`: telemetry is pruned on a retention schedule while sessions are kept forever, so an old session's window is legitimately empty and must not be blanked. + + THE ODOMETER IS NOT THE ONLY DISTANCE SOURCE + -------------------------------------------- + It used to be the only one read, which is useless on hardware whose + odometer resolves more coarsely than a typical trip. Every distance + source in the window is now measured and the finest one supplies + `distance_km`, while the odometer keeps `start_odometer` / `end_odometer` + to itself and wins ties. The measurements behind that, and the reason + the two key sets stay disjoint, live with the rule in + `app/utils/distance_counters.py`. """ if not session.started_at or not session.ended_at: return - # One grouped pass over the window. Odometer keys cannot be an `IN` - # list -- the standard SAE J1979 key carries an arbitrary two-hex-digit - # PID prefix (`A6-ODOMETER`) and a WiCAN autopid has none at all -- and - # a substring match would swallow trip counters like `21-DISTANCEMILON` - # (see app/utils/odometer_units.py). Grouping by key lets - # `is_odometer_param_key` decide in Python without a second scan, and - # keeps any function off the indexed `param_key` column. + # Which keys the window holds, so the readings query below can name them + # in an `IN` list of literals. They cannot be listed up front: the + # standard SAE J1979 odometer key carries an arbitrary two-hex-digit PID + # prefix (`A6-ODOMETER`) while a WiCAN autopid has none at all, and a + # substring match would swallow `DISTANCE_TO_EMPTY` (see + # app/utils/distance_counters.py). Grouping by key lets Python decide + # which are distance sources while keeping any function off the indexed + # `param_key` column. result = await self.db.execute( - select( - VehicleTelemetry.param_key, - func.min(VehicleTelemetry.value), - func.max(VehicleTelemetry.value), - ) + select(VehicleTelemetry.param_key) .where(VehicleTelemetry.vin == session.vin) # Scoped to the session's own device, not just its VIN. One vehicle # can carry both a WiCAN dongle and a Torque source, and @@ -383,23 +1085,28 @@ async def _calculate_session_distance(self, session: DriveSession) -> None: .where(VehicleTelemetry.timestamp <= session.ended_at) .group_by(VehicleTelemetry.param_key) ) - spans = [ - (low, high) - for key, low, high in result.all() - if low is not None and high is not None and is_odometer_param_key(key) - ] - - if spans: - low = min(pair[0] for pair in spans) - high = max(pair[1] for pair in spans) - session.start_odometer = float(low) - session.end_odometer = float(high) - session.distance_km = float(high) - float(low) - return - - # No odometer in the window. A Torque trip never has one -- the app - # reports no odometer PID -- so its distance comes from the GPS - # breadcrumb. This lives here rather than in `end_session` so every + source_keys = [key for (key,) in result.all() if is_distance_source_param_key(key)] + odometer_keys = {key for key in source_keys if is_odometer_param_key(key)} + + if source_keys: + travelled = await self._distance_by_source(session, source_keys) + + # The odometer columns are the odometer's alone, and stay a span + # rather than a sum: they answer "what did the clock read", not + # "how far did it move". + odometer_spans = [travelled[key] for key in odometer_keys] + if odometer_spans: + session.start_odometer = min(span.low for span in odometer_spans) + session.end_odometer = max(span.high for span in odometer_spans) + + best = select_distance_source(travelled, odometer_keys) + if best is not None: + session.distance_km = travelled[best].distance_km + return + + # No distance source in the window at all. A Torque trip never has + # one -- the app reports no odometer and no distance PID -- so its + # distance comes from the GPS breadcrumb. This lives here rather than in `end_session` so every # caller gets the same policy: computed only in `end_session`, a # Torque session's distance stayed frozen at whatever the breadcrumb # held on close while its speed and RPM were repaired around it. @@ -410,6 +1117,44 @@ async def _calculate_session_distance(self, session: DriveSession) -> None: coords = [(float(p.latitude), float(p.longitude)) for p in points] session.distance_km = float(LocationService.haversine_km(coords)) + async def _distance_by_source( + self, session: DriveSession, source_keys: Sequence[str] + ) -> dict[str, TravelledSpan]: + """Measure how far each distance source moved inside the session window. + + One ordered pass over only the keys the window actually holds, so the + `IN` list is literals discovered by the caller rather than a function + applied to the indexed `param_key` column. `uq_telemetry_dedup` + (`device_id`, `param_key`, `timestamp`) serves both the lookup and the + ordering, so no sort is needed on either dialect. + + The readings come back to Python rather than folding into a window + function in SQL, which reverses the rule + `movement_keys.speed_param_key_candidates` states for the aggregate + reader. Measured on a full 3,262-session rebuild of the instance this + was written for, it costs 3 ms: sessions average 9 distance-source rows + because only source keys are fetched, never the whole window. Folding it + into SQL would raise the worst case from 2,701 rows to 35,049. + """ + rows = await self.db.execute( + select( + VehicleTelemetry.param_key, + VehicleTelemetry.value, + ) + .where(VehicleTelemetry.vin == session.vin) + .where(VehicleTelemetry.device_id == session.device_id) + .where(VehicleTelemetry.timestamp >= session.started_at) + .where(VehicleTelemetry.timestamp <= session.ended_at) + .where(VehicleTelemetry.param_key.in_(list(source_keys))) + .order_by(VehicleTelemetry.param_key, VehicleTelemetry.timestamp) + ) + + series: dict[str, list[float]] = {} + for key, value in rows.all(): + series.setdefault(key, []).append(float(value)) + + return {key: measure_travelled(values) for key, values in series.items()} + async def _calculate_session_aggregates(self, session: DriveSession) -> None: """Calculate aggregate statistics for a session from telemetry data.""" if not session.started_at or not session.ended_at: @@ -421,7 +1166,7 @@ async def _calculate_session_aggregates(self, session: DriveSession) -> None: # (e.g. OBD2 PID-prefixed "0D-VehicleSpeed" vs generic "SPEED"). aggregate_mappings = { "speed": (SPEED_PARAM_KEYS, "avg_speed", "max_speed"), - "rpm": (["ENGINE_RPM", "0C-EngineRPM", "0C-ENGINERPM"], "avg_rpm", "max_rpm"), + "rpm": (RPM_PARAM_KEYS, "avg_rpm", "max_rpm"), "coolant": ( ["COOLANT_TMP", "05-EngineCoolantTemp", "05-ENGINECOOLANTTEMP"], "avg_coolant_temp", @@ -486,7 +1231,7 @@ async def _calculate_driving_insights(self, session: DriveSession) -> None: idle_seconds = 0.0 harsh_accel = 0 harsh_brake = 0 - idle_threshold_kmh = 5.0 + idle_threshold_kmh = IDLE_THRESHOLD_KMH harsh_ms2 = 3.5 # m/s² # Convert km/h/s to m/s²: 1 km/h/s = 1000/3600 m/s² ≈ 0.2778 harsh_kmh_per_s = harsh_ms2 / (1000.0 / 3600.0) @@ -559,39 +1304,126 @@ async def _get_param_stats_multi( # Timeout Detection # ========================================================================= - async def check_session_timeouts(self, timeout_minutes: int = 5) -> list[DriveSession]: - """Check for sessions that have timed out due to no data. - - This is called periodically by the background task to detect - sessions where the device lost connection without proper ECU offline. + async def check_session_timeouts( + self, + timeout_minutes: int = 5, + gap_minutes: int | None = None, + now: datetime | None = None, + ) -> list[DriveSession]: + """Close open sessions on either of TWO clocks, and never at last contact. + + Two clocks, because the five-minute setting is a CONNECTION-LOSS + detector and must not double as a drive-splitter: + + =============== ================================== ===================== + Clock Setting Measured from + =============== ================================== ===================== + Drive gap ``livelink_session_gap_minutes`` ``last_movement_at`` + Contact loss ``livelink_session_timeout_minutes`` ``last_seen`` + =============== ================================== ===================== + + The drive gap is checked FIRST, and its closure is final. Contact loss + closes just as promptly -- a device that never returns must not be left + open -- but RETAINS the pointer, so movement returning inside the gap + reopens the same session rather than creating a second one. Without that + distinction, movement then six minutes of silence then movement gives + two sessions live and one on replay, for the same journey. + + A vehicle stationary but still connected is in neither state: it is + ``stopped``, and it closes on the drive gap. So a six-minute charge, a + fuel stop or a drive-through no longer splits a drive, while a + twenty-minute stop still does -- which is what a person would call two + trips. An ICE vehicle idling at a light keeps RPM and survived the old + rule; a stationary EV reports neither speed nor RPM, so it is precisely + the vehicle the old rule shredded. + + **Neither clock stamps ``ended_at`` from its own cutoff.** The previous + implementation selected on ``last_seen`` and then called + ``end_session(device, last_seen)`` -- and ``update_device_status`` sets + ``last_seen`` on EVERY call, heartbeat included. Changing only the + selection predicate would leave every drive's tail padded by up to one + heartbeat interval (95 minutes, measured) and drag ``avg_speed`` toward + zero with parked samples, re-widening the window PR #157 narrowed. Both + clocks close at ``movement_ended_at``. Args: - timeout_minutes: Minutes of inactivity before timeout + timeout_minutes: Contact-loss timeout. + gap_minutes: Drive-gap threshold; read from settings when omitted. + now: Injected clock, for tests. Returns: - List of sessions that were closed due to timeout + List of sessions that were closed. """ - cutoff = utc_now().replace(tzinfo=None) - timedelta(minutes=timeout_minutes) + now = self._naive(now or utc_now()) + if gap_minutes is None: + gap_minutes = await self._gap_minutes() + contact_cutoff = now - timedelta(minutes=timeout_minutes) + gap_cutoff = now - timedelta(minutes=gap_minutes) closed_sessions = [] - # Find devices with active sessions that haven't been seen recently - result = await self.db.execute( - select(LiveLinkDevice) - .where(LiveLinkDevice.current_session_id.isnot(None)) - .where(LiveLinkDevice.last_seen < cutoff) - ) - stale_devices = result.scalars().all() - - for device in stale_devices: - # End the session at the last seen time - last_seen = device.last_seen or utc_now().replace(tzinfo=None) - session = await self.end_session(device, last_seen) - if session: - closed_sessions.append(session) + # Joined to the session and filtered to OPEN ones: in the `awaiting` + # state the pointer deliberately names a CLOSED session, and selecting + # on the pointer alone would try to close it again on every tick. + rows = ( + await self.db.execute( + select(LiveLinkDevice, DriveSession) + .join(DriveSession, DriveSession.id == LiveLinkDevice.current_session_id) + .where(DriveSession.ended_at.is_(None)) + ) + ).all() + + for device, session in rows: + last_seen = self._naive(device.last_seen) if device.last_seen else now + + # A session this algorithm did not cut gets the OLD rule, whole: + # close on contact loss, at the last contact. + # + # In practice that is Torque, whose boundaries come from the phone. + # It has no movement record of any kind -- `resolve_torque_session` + # never calls the observer, deliberately -- so the drive gap would + # fall back to `started_at` and close an actively-uploading trip + # fifteen minutes after it BEGAN, cutting a one-hour drive into a + # quarter-hour session and forty-five minutes belonging to nothing. + # `check_session_timeouts` has no `kind` filter, so without this the + # gap clock reaches a source that was working correctly. + if session.boundary_algorithm_version < BOUNDARY_ALGORITHM_MOVEMENT: + if last_seen < contact_cutoff: + ended = await self.end_session(device, last_seen) + if ended: + closed_sessions.append(ended) + logger.info( + "Closed session %d for device %s on contact loss at %s " + "(pre-movement boundaries, closed at last contact)", + ended.id, + device.device_id, + last_seen, + ) + continue + + moved_at = self._naive( + device.last_movement_at + or session.movement_ended_at + or session.movement_started_at + or session.started_at + ) + + if moved_at < gap_cutoff: + reason, retain = "drive gap", False + elif last_seen < contact_cutoff: + reason, retain = "contact loss", True + else: + continue + + ended = await self.end_session(device, moved_at, retain_pointer=retain) + if ended: + closed_sessions.append(ended) logger.info( - "Closed session %d for device %s due to timeout", - session.id, + "Closed session %d for device %s on %s at %s (last contact %s)", + ended.id, device.device_id, + reason, + moved_at, + last_seen, ) if closed_sessions: @@ -615,8 +1447,28 @@ async def get_vehicle_sessions( offset: int = 0, start: datetime | None = None, end: datetime | None = None, + include_stationary: bool = True, ) -> list[DriveSession]: - """Get sessions for a vehicle.""" + """Get sessions for a vehicle. + + ``include_stationary=False`` hides sessions with no evidence the vehicle + moved. A parked WiCAN checks in about every 95 minutes and, under the + pre-098 contact rule, each check-in opened a session: on the instance + this was built against, 2,921 of 3,262 recorded sessions never moved at + all. They cannot be rebuilt into real drives, because the telemetry that + would prove where a drive began and ended was never captured, and they + must not be deleted: a release that tried removed 2,700 km of genuinely + recorded distance. + + Filters on MOVEMENT, not on ``boundary_algorithm_version``. Hiding by + algorithm buries 341 of those same sessions in which the vehicle + demonstrably DID move, which is real history and the user's own record + of it. What makes a row worthless is that nothing moved, not which rule + cut it. + + A property of the VIEW. Every row stays, the default shows all of them, + and the caller that narrows says so explicitly. + """ query = ( select(DriveSession) .where(DriveSession.vin == vin) @@ -627,16 +1479,39 @@ async def get_vehicle_sessions( query = query.where(DriveSession.started_at >= start) if end: query = query.where(DriveSession.ended_at <= end) + if not include_stationary: + query = query.where(_moved_predicate()) query = query.offset(offset).limit(limit) result = await self.db.execute(query) return list(result.scalars().all()) - async def get_session_count(self, vin: str) -> int: - """Get total session count for a vehicle.""" + async def get_session_count(self, vin: str, include_stationary: bool = True) -> int: + """Total session count for a vehicle, under the same filter as the list. + + Takes ``include_stationary`` because a total that counted rows the list + refuses to show would leave the last page of a filtered list + permanently empty, and report a number of drives the UI never renders. + """ + query = select(func.count(DriveSession.id)).where(DriveSession.vin == vin) + if not include_stationary: + query = query.where(_moved_predicate()) + result = await self.db.execute(query) + row = result.first() + return row[0] if row else 0 + + async def get_stationary_session_count(self, vin: str) -> int: + """How many of a vehicle's sessions show no evidence it ever moved. + + Reported alongside a filtered list so the view can name what it is + holding back. A list showing nothing is indistinguishable from a broken + one unless it can say why it is empty. + """ result = await self.db.execute( - select(func.count(DriveSession.id)).where(DriveSession.vin == vin) + select(func.count(DriveSession.id)) + .where(DriveSession.vin == vin) + .where(~_moved_predicate()) ) row = result.first() return row[0] if row else 0 diff --git a/backend/app/services/telemetry_service.py b/backend/app/services/telemetry_service.py index 10f7ccfd..95cf3072 100644 --- a/backend/app/services/telemetry_service.py +++ b/backend/app/services/telemetry_service.py @@ -38,6 +38,52 @@ from app.utils.odometer_units import odometer_value_to_km +class MaintenanceModeError(RuntimeError): + """Raised when a telemetry write is attempted during maintenance mode. + + The upgrade procedure for the odometer repair tools needs a window in which + no new reading lands: a single reading arriving mid-repair recreates the + mixed-unit state the tools refuse to run against. + + This lives on the writers rather than only on the routes because two rounds + of review found an entry point the route gate did not cover -- first the + admin SD backfill route, then `POST /api/livelink/mqtt/restart`, which + writes nothing itself but starts the MQTT subscriber. MQTT is not a route, + the scheduler is not a route, and the next ingest path need not be one + either. Enumerating entry points is a floor; this is the choke point they + all pass through. + """ + + +#: How far a device-supplied sample timestamp may sit from its arrival time and +#: still count as "live" for the purpose of anchoring ``last_movement_at``. +#: +#: Matches the default contact-loss timeout, deliberately: a sample that is +#: further from now than the timeout that would have closed the session cannot +#: be describing the session that is open. Beyond this the reading is treated as +#: replay -- still real driving, still allowed to open and extend a session, but +#: not allowed to move the anchor every live timeout measures from. +LIVE_SAMPLE_TOLERANCE_SECONDS = 300 + + +def _refuse_if_in_maintenance(operation: str) -> None: + """Raise if maintenance mode is on. + + Args: + operation: Name of the write being attempted, for the log and message. + + Raises: + MaintenanceModeError: If ``settings.maintenance_mode`` is set. + """ + from app.config import settings + + if settings.maintenance_mode: + logger.warning("Maintenance mode: refused %s", operation) + raise MaintenanceModeError( + f"{operation} refused: MyGarage is in maintenance mode and is not accepting telemetry." + ) + + @dataclass class StoreResult: """Result from store_telemetry() with stored count and validated data.""" @@ -354,6 +400,7 @@ async def store_telemetry( Returns: StoreResult with stored count and validated data """ + _refuse_if_in_maintenance("store_telemetry") if timestamp is None: timestamp = utc_now() @@ -448,6 +495,22 @@ async def store_telemetry( # Duplicate (same device_id, param_key, timestamp) - skip pass + # Decide what this batch means for the device's drive session. + # + # Here rather than at each caller, because there are THREE live ingest + # sites -- MQTT `can/rx`, MQTT `can/status`, and the HTTPS payload route + # -- and all three funnel through this method. An earlier design + # revision hooked only the MQTT telemetry path, whose own comment + # describes it as the FALLBACK for "WiCAN devices that don't send + # explicit can/status messages": an instance whose dongle sends status + # messages, or any instance on HTTPS ingest, would have kept 100% of its + # phantom sessions while the changelog claimed otherwise. + # + # Reads `valid_data`, not the raw batch: the validator has already + # dropped out-of-range garbage, and a spurious 400 km/h reading must not + # be what opens a drive. + await self._observe_movement(vin, device_id, valid_data, timestamp, received_at) + # Check for odometer reading and sync await self._sync_odometer_from_telemetry(vin, autopid_data, timestamp) @@ -456,6 +519,45 @@ async def store_telemetry( return StoreResult(stored_count=stored_count, validated_data=valid_data) + async def _observe_movement( + self, + vin: str, + device_id: str, + samples: dict[str, float | int | str | None], + sample_at: datetime, + received_at: datetime, + ) -> None: + """Feed one validated batch to the session state machine. + + ``sample_at`` is the device's own reading time and ``received_at`` is + when it arrived here. The two are distinguished because they diverge: + MQTT stamps ``utc_now()`` unconditionally, while the HTTPS route accepts + an optional device timestamp that a buffering dongle sets hours in the + past -- or, with a bad clock plugin, in the future. + + A sample far from receipt time may still open and extend a session (it + is real driving that happened), but must not anchor + ``last_movement_at``, which every live timeout measures from. Anchoring + a live session on a replayed timestamp drags it hours away from where it + belongs and makes the contact-loss clock fire against a moment the + device never spoke. + """ + from app.services.session_service import SessionService # local import avoids cycle + + device = ( + await self.db.execute( + select(LiveLinkDevice).where(LiveLinkDevice.device_id == device_id) + ) + ).scalar_one_or_none() + if device is None: + return + + sample = sample_at.replace(tzinfo=None) if sample_at.tzinfo else sample_at + arrival = received_at.replace(tzinfo=None) if received_at.tzinfo else received_at + live = abs((arrival - sample).total_seconds()) <= LIVE_SAMPLE_TOLERANCE_SECONDS + + await SessionService(self.db).observe_telemetry(device, samples, sample, live=live) + async def _refresh_closed_session(self, vin: str, device_id: str, timestamp: datetime) -> None: """Recompute aggregates for a CLOSED session this reading falls inside. @@ -666,6 +768,7 @@ async def bulk_backfill(self, vin: str, device_id: str, rows: list) -> int: Returns the number of rows actually inserted (conflict-skipped rows are not counted). """ + _refuse_if_in_maintenance("bulk_backfill") if not rows: return 0 @@ -676,6 +779,7 @@ async def bulk_backfill(self, vin: str, device_id: str, rows: list) -> int: commit_batch = 500 inserted = 0 stamps: list[datetime] = [] + observed: list[tuple[datetime, str, float]] = [] # The SD path writes `r.value` straight to a metric-canonical column, # so it needs the same odometer conversion `store_telemetry` applies: @@ -694,6 +798,11 @@ async def bulk_backfill(self, vin: str, device_id: str, rows: list) -> int: converted = odometer_value_to_km(value, r.param_key, device_unit, device_kind) if converted is not None: value = converted + # Kept for the session reconstruction below, in CANONICAL km. The + # predicate compares odometer readings against each other, so a + # batch mixing raw miles with converted kilometres would see + # increases and decreases that never happened. + observed.append((ts, r.param_key, value)) # Use the module-level dialect_insert (sqlite or pg, chosen at import time) stmt = ( @@ -742,11 +851,149 @@ async def bulk_backfill(self, vin: str, device_id: str, rows: list) -> int: # loops over log files (`SdBackfillService._backfill`), so this is once # per file, not once per pull. if stamps: + # Create the sessions these rows describe BEFORE refreshing, so a + # newly created session is included in the refresh rather than + # waiting for the next pull. This is the motivating case for the + # whole boundary rework: `_refresh_sessions_in_span` selects + # `ended_at IS NOT NULL`, i.e. sessions that already exist and are + # already closed, so this path had never created one -- and it is + # the only path for anything driven out of broker range. + # + # Once per call, not once per row: running the live side-effects per + # row is precisely what `bulk_backfill` exists to avoid. + await self._reconstruct_sessions_from_batch(vin, device_id, observed) await self._refresh_sessions_in_span(vin, device_id, min(stamps), max(stamps)) await self.db.commit() return inserted + async def _reconstruct_sessions_from_batch( + self, + vin: str, + device_id: str, + observed: list[tuple[datetime, str, float]], + ) -> int: + """Create or extend the drive sessions a replayed batch describes. + + Returns the number of sessions created or extended. + + The grouping is `session_boundaries.group_drives`, which applies the + same movement predicate and the same gap as the live path -- so a drive + is cut the same way whether it arrived over MQTT or off an SD card. + + Three rules keep this from making things worse than the bug it fixes: + + - **Torque sessions are never touched.** The phone supplies an + authoritative session id; re-bounding it on a gap threshold replaces + good evidence with inference. Excluded by `external_session_id`, not + by heuristic. + - **No overlaps are written.** Nothing in the schema forbids them, and + every aggregate is a window scan, so two overlapping sessions both + claim the same samples and both report the same distance. + - **An ambiguous window is left alone.** A drive overlapping more than + one existing session is a history-repair problem, not a backfill one; + merging them here would be a destructive guess made during an ingest. + """ + from app.services.session_boundaries import BOUNDARY_ALGORITHM_MOVEMENT, group_drives + + device = ( + await self.db.execute( + select(LiveLinkDevice).where(LiveLinkDevice.device_id == device_id) + ) + ).scalar_one_or_none() + if device is None or not device.vin: + return 0 + + from app.services.livelink_service import LiveLinkService # local: avoids a cycle + + gap = await LiveLinkService(self.db).get_session_gap_minutes() + drives = group_drives(observed, gap) + if not drives: + return 0 + + changed = 0 + for drive in drives: + overlapping = list( + ( + await self.db.execute( + select(DriveSession) + .where(DriveSession.device_id == device_id) + .where(DriveSession.external_session_id.is_(None)) + .where(DriveSession.started_at <= drive.movement_ended_at) + .where( + func.coalesce(DriveSession.ended_at, DriveSession.started_at) + >= drive.started_at + ) + ) + ) + .scalars() + .all() + ) + + if len(overlapping) > 1: + logger.info( + "SD reconstruction: drive %s..%s for %s spans %d existing sessions; " + "left unchanged (history repair, not ingest)", + drive.started_at, + drive.movement_ended_at, + device_id, + len(overlapping), + ) + continue + + if overlapping: + session = overlapping[0] + if session.ended_at is None: + # An OPEN session is the live path's business: it is still + # deciding where this drive ends, and closing it here would + # race the timeout clocks. + continue + session.started_at = min(self._naive_ts(session.started_at), drive.started_at) + session.ended_at = max(self._naive_ts(session.ended_at), drive.movement_ended_at) + session.movement_started_at = min( + self._naive_ts(session.movement_started_at) or drive.movement_started_at, + drive.movement_started_at, + ) + session.movement_ended_at = max( + self._naive_ts(session.movement_ended_at) or drive.movement_ended_at, + drive.movement_ended_at, + ) + else: + session = DriveSession( + vin=device.vin, + device_id=device_id, + started_at=drive.started_at, + ended_at=drive.movement_ended_at, + movement_started_at=drive.movement_started_at, + movement_ended_at=drive.movement_ended_at, + boundary_algorithm_version=BOUNDARY_ALGORITHM_MOVEMENT, + effective_gap_minutes=gap, + ) + self.db.add(session) + logger.info( + "SD reconstruction: created session for %s over %s..%s", + device_id, + drive.started_at, + drive.movement_ended_at, + ) + + # Read back into locals and narrow. Both were assigned non-None a + # few lines up, in either branch, but they are nullable columns and + # a later edit to one branch could stop being true without anything + # here noticing. + window_start = self._naive_ts(session.started_at) + window_end = self._naive_ts(session.ended_at) + if window_start is not None and window_end is not None: + session.duration_seconds = max(0, int((window_end - window_start).total_seconds())) + changed += 1 + + await self.db.flush() + return changed + + @staticmethod + def _naive_ts(value: datetime | None) -> datetime | None: + return value.replace(tzinfo=None) if value is not None and value.tzinfo else value + async def _refresh_sessions_in_span( self, vin: str, device_id: str, start: datetime, end: datetime ) -> None: @@ -799,6 +1046,7 @@ async def store_torque_telemetry( Does NOT commit — the caller owns the transaction. `values` keys are already canonical param_keys (see torque_pid_map). `timestamp` must be naive UTC. """ + _refuse_if_in_maintenance("store_torque_telemetry") if not values: return 0 ts = timestamp.replace(tzinfo=None) if timestamp.tzinfo is not None else timestamp @@ -886,7 +1134,7 @@ async def get_latest_values(self, vin: str) -> list[VehicleTelemetryLatest]: # against the clock, so a single dongle with a wrong date would push the # cutoff past every normally-dated parameter and blank the dashboard # until real time caught up. - now = utc_now().replace(tzinfo=None) + now = utc_now() plausible = [row.timestamp for row in rows if row.timestamp <= now] newest = max(plausible) if plausible else now cutoff = newest - LATEST_VALUE_STALE_AFTER diff --git a/backend/app/services/tire_results.py b/backend/app/services/tire_results.py new file mode 100644 index 00000000..5a093d4d --- /dev/null +++ b/backend/app/services/tire_results.py @@ -0,0 +1,114 @@ +"""Typed results for tire distance and wear. + +Both calculations used to answer `Decimal | None`, and the null carried no +reason. A caller then had to distinguish "no mount recorded", "a period is +missing an odometer bound", "the odometer went backwards", "this tire has only +ever been a spare" and "this figure came from the legacy raw-delta path" from a +single `None` -- information the return type had already thrown away. The +result was that every null rendered as one message, usually the wrong one, and +the analytics surface was pushed toward re-implementing the calculation. + +The statuses are EXHAUSTIVE and every caller handles every member. A caller +that falls through on one renders a silent zero, which for a tire means telling +someone their tread is fine. +""" + +from __future__ import annotations + +import datetime as dt +from dataclasses import dataclass, field +from decimal import Decimal +from enum import StrEnum + + +class DistanceStatus(StrEnum): + """Why `distance_on_tire` did or did not produce an all-time figure.""" + + #: Every period has both odometer bounds. `all_time_value` is the answer. + COMPLETE = "complete" + #: Some periods are bounded and some are not. `known_value` is the part + #: that IS measurable; the all-time total is withheld. + INCOMPLETE = "incomplete" + #: Periods exist but NONE has both bounds, so there is no subtotal either. + #: This is the state of every tire migrated by 097 on upgrade day, because + #: the assumed period it creates has a null start odometer. It is the + #: common case, not an edge one, and it must never render as "0 km". + NOTHING_BOUNDED = "nothing_bounded" + #: No mount period recorded at all. + NO_PERIODS = "no_periods" + #: Every period is at SPARE. The tire has never rolled, which is a state + #: rather than a measurement of zero. + SPARE_ONLY = "spare_only" + #: A period ends below where it started. A data fault, not a gap: the + #: repair is to correct a number, not to supply a missing one. + ODOMETER_ROLLBACK = "odometer_rollback" + + +class WearStatus(StrEnum): + """Why `project_wear` did or did not produce a projection.""" + + #: Two bounded readings on a period-aware distance. + PROJECTED = "projected" + #: Already at or past the replacement threshold. Replace now. + AT_OR_BELOW_MINIMUM = "at_or_below_minimum" + #: `min_tread_mm` is null. There is NO 2.0 fallback: the 2.0 is a column + #: default that applies at insert, not to a row already holding null. + NO_MINIMUM_SET = "no_minimum_set" + #: Fewer than two tread-bearing readings. One reading is a point. + INSUFFICIENT_READINGS = "insufficient_readings" + #: The readings carry no odometer, so there is no distance to wear against. + NO_READING_ODOMETERS = "no_reading_odometers" + #: Tread flat or increasing between the two readings, so no wear rate + #: exists. Distinct from NO_DISTANCE_ON_TIRE: "your tread is not going + #: down" and "you have not driven on this tire" need different prompts, + #: and the pre-v3.3.0 code could not tell a caller which had happened. + TREAD_NOT_DECREASING = "tread_not_decreasing" + #: Distance on this tire is unavailable, so there is no denominator. + NO_DISTANCE_ON_TIRE = "no_distance_on_tire" + #: Would have come from the legacy raw-delta path, which treats the whole + #: odometer span between two readings as distance driven on THIS tire. + #: For a two-set owner that is wrong by the distance driven on the other + #: set, erring high -- the dangerous direction for a tire. Suppressed, not + #: labelled: an "estimate" badge does not communicate that 648,000 km is + #: structurally invalid rather than merely imprecise. + UNVERIFIED_MOUNT_HISTORY = "unverified_mount_history" + + +@dataclass(frozen=True) +class DistanceResult: + """Distance driven on one tire, with the reason when it is unknown.""" + + status: DistanceStatus + #: Total across every period. Non-null only when status is COMPLETE. + all_time_value: Decimal | None = None + #: Sum over the periods that DO have both bounds. Non-null for COMPLETE + #: and INCOMPLETE. This is the field that stops a migrated tire from + #: reporting nothing forever once part of its history becomes measurable. + known_value: Decimal | None = None + #: `mounted_on` of the earliest period contributing to `known_value`. + #: Only ever taken from a period that actually contributed, so an assumed + #: period's null start cannot poison it. + known_since: dt.date | None = None + #: Periods the user must act on. Populated for INCOMPLETE (missing bounds) + #: and ODOMETER_ROLLBACK (a faulted period); the STATUS says which kind. + blocking_period_ids: list[int] = field(default_factory=list) + + +@dataclass(frozen=True) +class WearResult: + """A wear projection, with the reason when there is none. + + Carries TWO values, not one. `_project_wear` has always returned + `(km_remaining, wear_date)` and both are on the wire as + `projected_km_remaining` and `projected_wear_date`, rendered together by + the tire card. A single-value result type would have silently deleted the + date from the API. + """ + + status: WearStatus + km_remaining: Decimal | None = None + #: Null even on a successful projection when the two readings are same-day + #: (`day_delta == 0`) or the derived rate is non-positive. So it is not a + #: proxy for "did this project": read `status` for that. + wear_date: dt.date | None = None + blocking_period_ids: list[int] = field(default_factory=list) diff --git a/backend/app/services/tire_service.py b/backend/app/services/tire_service.py index 83ab9c76..ae71efaa 100644 --- a/backend/app/services/tire_service.py +++ b/backend/app/services/tire_service.py @@ -2,60 +2,306 @@ from __future__ import annotations +import datetime as dt import logging +from collections.abc import Sequence from datetime import timedelta from decimal import Decimal from fastapi import HTTPException -from sqlalchemy import select +from sqlalchemy import delete, select, update from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.models.odometer import OdometerRecord from app.models.reminder import Reminder -from app.models.tire import Tire, TireReading +from app.models.tire import Tire, TireMountPeriod, TireReading, TireSet from app.models.user import User from app.schemas.tire import ( - TIRE_POSITIONS, + MountPeriodResponse, TireCreate, + TireCreateAndMountRequest, + TireDismountRequest, TireListResponse, + TireMountRequest, TireReadingCreate, TireReadingResponse, TireResponse, + TireRotationRequest, TireUpdate, ) +from app.services.tire_results import ( + DistanceResult, + DistanceStatus, + WearResult, + WearStatus, +) from app.utils.datetime_utils import utc_now from app.utils.logging_utils import sanitize_for_log +from app.utils.odometer_sync import auto_sync_marker, sync_odometer_from_record logger = logging.getLogger(__name__) +# `source_type` for the odometer readings the tire paths publish. Two of them, +# and the split decides what happens when a tire is deleted: a mount, dismount, +# retire or reading odometer exists because of ONE tire and goes with it, while +# a rotation's is a reading of the VEHICLE taken while several tires were on +# it. Deleting one of those tires does not make the reading untrue, and +# cascading it would break the distance figure for every other tire in the same +# rotation. Both fit `odometer_records.source` VARCHAR(20) -- a length +# PostgreSQL enforces and SQLite does not. +ODOMETER_SOURCE_TIRE = "tire" +ODOMETER_SOURCE_ROTATION = "tire_rotation" +ODOMETER_SOURCE_SET = "tire_set" + + +async def apply_mount_moves( + db: AsyncSession, + *, + vacate: Sequence[Tire], + assign: Sequence[tuple[Tire, str]], + when: dt.date, + odometer_km: Decimal | None, + notes: str | None = None, +) -> None: + """Take tires off corners and put tires onto corners, in two phases. + + **Why two phases.** `uq_tires_vin_position` is an IMMEDIATE unique index on + both dialects, so assigning one tire at a time fails the moment a + destination is still occupied -- which for a rotation or a seasonal swap is + always. An X-pattern collides on the very first move even though the + requested FINAL arrangement is perfectly legal. So: clear every affected + `tires.position` and close every affected period, FLUSH, then assign the new + positions and open the new periods. Deferring the constraint is not an + option; SQLite has no `DEFERRABLE INITIALLY DEFERRED`. + + A tire in `vacate` and not in `assign` ends up stored, which is what a + seasonal swap does to the set coming off. A tire in `assign` and not in + `vacate` is a stored tire being fitted, and has no open period to close. + + Extracted when tire sets arrived, because the alternative was a second copy + of the vacate/flush/assign dance -- the subtlest thing in the tire service, + and the one whose absence does not fail loudly but corrupts an arrangement. + + Args: + vacate: Tires to take off, closing each one's open period at + `when`/`odometer_km`. + assign: `(tire, position)` pairs to fit, opening a period for each. + when: The date both halves are recorded at. + odometer_km: The vehicle's odometer, bounding both halves. + notes: Free text copied onto every period this opens. + """ + vacate_ids = [tire.id for tire in vacate] + open_periods = {} + if vacate_ids: + open_periods = { + period.tire_id: period + for period in ( + await db.execute( + select(TireMountPeriod).where( + TireMountPeriod.tire_id.in_(vacate_ids), + TireMountPeriod.dismounted_on.is_(None), + ) + ) + ) + .scalars() + .all() + } + + # ---- Phase 1: vacate. ----------------------------------------------- + for tire in vacate: + tire.position = None + period = open_periods.get(tire.id) + if period is not None: + period.dismounted_on = when + period.dismounted_odometer_km = odometer_km + # The flush is the point of the split: without it the assignments below + # race the old values still sitting in the unique index. + await db.flush() + + # ---- Phase 2: assign. ----------------------------------------------- + for tire, position in assign: + tire.position = position + db.add( + TireMountPeriod( + tire_id=tire.id, + position=position, + mounted_on=when, + mounted_odometer_km=odometer_km, + is_assumed=False, + notes=notes, + ) + ) + + +def distance_on_tire(tire: Tire, current_odometer: Decimal | None) -> DistanceResult: + """Distance driven ON THIS TIRE, summed over its mount periods. + + This is the calculation the whole mount-period model exists for. The old + one took the raw odometer delta between two readings, which for anyone + running a second seasonal set counts the distance driven on the OTHER set. + + Args: + tire: The tire, with `mount_periods` loaded. + current_odometer: The vehicle's latest odometer reading, used as the + upper bound of any period still open. None when the vehicle has no + odometer record at all, which makes an open period unbounded. + + Returns: + A `DistanceResult` whose `status` says why a figure is or is not + available. Never a bare zero: "this tire has never rolled" and "this + tire rolled zero kilometres" are different answers. + """ + periods = list(tire.mount_periods or []) + if not periods: + return DistanceResult(status=DistanceStatus.NO_PERIODS) + + # A spare accrues nothing: it is in the trunk while the vehicle drives. + rolling = [p for p in periods if p.position != "SPARE"] + if not rolling: + return DistanceResult(status=DistanceStatus.SPARE_ONLY) + + known = Decimal("0") + earliest: dt.date | None = None + contributed = 0 + blocking: list[int] = [] + + for period in rolling: + start = period.mounted_odometer_km + end = ( + period.dismounted_odometer_km if period.dismounted_on is not None else current_odometer + ) + if start is None or end is None: + blocking.append(period.id) + continue + if end < start: + return DistanceResult( + status=DistanceStatus.ODOMETER_ROLLBACK, + blocking_period_ids=[period.id], + ) + known += end - start + contributed += 1 + # Only a period that CONTRIBUTED can date the known figure, and its + # `mounted_on` may still be null on a migrated assumed period. + if period.mounted_on is not None: + earliest = period.mounted_on if earliest is None else min(earliest, period.mounted_on) + + if contributed == 0: + # Every migrated tire, on upgrade day. NOT `incomplete`: there is no + # subtotal to show, and "0 km since an unknown date" is worse than + # saying nothing. + return DistanceResult(status=DistanceStatus.NOTHING_BOUNDED, blocking_period_ids=blocking) + if blocking: + return DistanceResult( + status=DistanceStatus.INCOMPLETE, + known_value=known, + known_since=earliest, + blocking_period_ids=blocking, + ) + return DistanceResult( + status=DistanceStatus.COMPLETE, + all_time_value=known, + known_value=known, + known_since=earliest, + ) + + +def project_wear( + tire: Tire, + current_odometer: Decimal | None, + readings: list[TireReading] | None = None, +) -> WearResult: + """Estimate remaining tread life, with the reason when there is none. -def _project_wear( - readings: list[TireReading], - min_tread: Decimal | None, -) -> tuple[Decimal | None, object | None]: - """Estimate km remaining and wear date from the two most recent readings. + Two changes from the pre-v3.3.0 `_project_wear`, both of which were + producing wrong or missing numbers in production: - Requires two readings with odometer and decreasing tread. Returns - ``(projected_km_remaining, projected_wear_date)``. + 1. It is **period-aware**. The old one used the raw odometer delta between + the two readings as distance driven on this tire. That is only correct + for someone who has never had a second set, and it errs HIGH -- the + dangerous direction. Where the mount history cannot support the figure + it is now withheld (`UNVERIFIED_MOUNT_HISTORY`) rather than published + with an "estimate" badge. + + 2. It **sorts its own readings**. `_sync_low_tread_reminder` passed them + unsorted, so `readings[0]` was the OLDEST, `tread_delta` came out + negative, and the low-tread projection has been silently missing from + every reminder. Selection moved inside so this surface and the reminder + quote the same number by construction. + + Args: + tire: The tire, with `mount_periods` loaded. + current_odometer: The vehicle's latest odometer, for open periods. + readings: Override for the reading list; defaults to the tire's own. + Passed in by callers that already loaded them. + + Returns: + A `WearResult`. Its `status` is exhaustive: every current null exit of + the old function maps to a named member, so a caller can say WHICH + input is missing instead of rendering one message for five states. """ - if min_tread is None or len(readings) < 2: - return None, None - newer, older = readings[0], readings[1] - if ( - newer.odometer_km is None - or older.odometer_km is None - or newer.tread_depth_mm is None - or older.tread_depth_mm is None - ): - return None, None - km_delta = newer.odometer_km - older.odometer_km - tread_delta = older.tread_depth_mm - newer.tread_depth_mm - if km_delta <= 0 or tread_delta <= 0: - return None, None - remaining_tread = newer.tread_depth_mm - min_tread + candidates = sorted( + [r for r in (readings if readings is not None else tire.readings or [])], + key=lambda r: r.recorded_at, + reverse=True, + ) + min_tread = tire.min_tread_mm + + if min_tread is None: + # No 2.0 fallback: the 2.0 is a COLUMN default applied at insert, not + # to a row that already holds null. + return WearResult(status=WearStatus.NO_MINIMUM_SET) + + with_tread = [r for r in candidates if r.tread_depth_mm is not None] + if len(with_tread) < 2: + return WearResult(status=WearStatus.INSUFFICIENT_READINGS) + + newer, older = with_tread[0], with_tread[1] + if newer.odometer_km is None or older.odometer_km is None: + return WearResult(status=WearStatus.NO_READING_ODOMETERS) + + newer_tread, older_tread = newer.tread_depth_mm, older.tread_depth_mm + if newer_tread is None or older_tread is None: # pragma: no cover - filtered above + return WearResult(status=WearStatus.INSUFFICIENT_READINGS) + + tread_delta = older_tread - newer_tread + if tread_delta <= 0: + # Flat or increasing tread. Distinct from "you have not driven on this + # tire": the prompts differ, and the old code could not tell a caller + # which of the two had happened. + return WearResult(status=WearStatus.TREAD_NOT_DECREASING) + + # The distance is the tire's own, not the vehicle's odometer span. + distance = distance_on_tire(tire, current_odometer) + if distance.status is DistanceStatus.COMPLETE: + km_delta = newer.odometer_km - older.odometer_km + elif distance.status is DistanceStatus.NOTHING_BOUNDED: + # The migrated shape. The raw delta is exactly the legacy calculation + # this release exists to stop publishing. + return WearResult( + status=WearStatus.UNVERIFIED_MOUNT_HISTORY, + blocking_period_ids=distance.blocking_period_ids, + ) + else: + return WearResult( + status=WearStatus.NO_DISTANCE_ON_TIRE, + blocking_period_ids=distance.blocking_period_ids, + ) + + if km_delta <= 0: + return WearResult(status=WearStatus.NO_DISTANCE_ON_TIRE) + + remaining_tread = newer_tread - min_tread if remaining_tread <= 0: - return Decimal("0"), newer.recorded_at + # At or past the threshold. This is the SAFETY case: it carries a + # number and a date, and the reminder fires on it. + return WearResult( + status=WearStatus.AT_OR_BELOW_MINIMUM, + km_remaining=Decimal("0"), + wear_date=newer.recorded_at, + ) + mm_per_km = tread_delta / km_delta km_left = remaining_tread / mm_per_km day_delta = (newer.recorded_at - older.recorded_at).days @@ -65,7 +311,13 @@ def _project_wear( if km_per_day > 0: days_left = int(km_left / km_per_day) wear_date = newer.recorded_at + timedelta(days=max(days_left, 0)) - return km_left.quantize(Decimal("0.1")), wear_date + # `wear_date` stays None for same-day readings: the km figure is still + # valid, so status is PROJECTED and the date is simply unavailable. + return WearResult( + status=WearStatus.PROJECTED, + km_remaining=km_left.quantize(Decimal("0.1")), + wear_date=wear_date, + ) class TireService: @@ -74,42 +326,138 @@ class TireService: def __init__(self, db: AsyncSession): self.db = db - def _to_response(self, tire: Tire, include_readings: bool = True) -> TireResponse: + async def _current_odometer(self, vin: str) -> Decimal | None: + """The vehicle's latest odometer reading, in canonical km. + + Used as the upper bound for any mount period still open. There is no + odometer column on `Vehicle` -- it is a relationship -- so this is a + query, and it legitimately returns None for a vehicle that has never + had a reading. That is its own empty state, not a zero: every open + period on such a vehicle is unbounded. + """ + result = await self.db.execute( + select(OdometerRecord.odometer_km) + .where(OdometerRecord.vin == vin) + .order_by(OdometerRecord.date.desc(), OdometerRecord.id.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + async def _publish_odometer( + self, + vin: str, + when: dt.date, + odometer_km: Decimal | None, + source_type: str, + source_id: int, + ) -> None: + """Record a tire operation's odometer as a reading of the VEHICLE. + + Every tire write that takes an odometer was storing it on the mount + period and nowhere else, so `distance_on_tire` -- which bounds an OPEN + period with the vehicle's latest `OdometerRecord` -- could not see the + number the user had just typed. Rotating four tires and entering the + odometer left the distance `incomplete`, and the only way out was to go + and log the same reading a second time somewhere else. + + Always composed into the caller's transaction: `commit=False` is fixed + here rather than passed at each call site, because a commit + in the middle of a mount, a rotation or a retire splits the operation in + half. The shared helper's refusals come with it -- a manual reading on + the same date is never overwritten, and a null odometer is a no-op. + """ + await sync_odometer_from_record( + self.db, vin, when, odometer_km, source_type, source_id, commit=False + ) + + @staticmethod + def _derived_installed_date(tire: Tire) -> dt.date | None: + """The `mounted_on` of the earliest period THAT HAS ONE. + + Null when the earliest period is the migrated assumed one with an + unknown start. Deliberately not `MIN(mounted_on)` over the non-null + values only: that would skip the unknown and report a later REMOUNT + date as the installation date, which reads as fact and is wrong. + """ + periods = sorted( + tire.mount_periods or [], key=lambda mp: (mp.mounted_on or dt.date.min, mp.id) + ) + if not periods: + return None + return periods[0].mounted_on + + def _to_response( + self, + tire: Tire, + include_readings: bool = True, + current_odometer: Decimal | None = None, + ) -> TireResponse: readings = sorted( list(tire.readings or []), key=lambda r: r.recorded_at, reverse=True, ) - km_left, wear_date = _project_wear(readings, tire.min_tread_mm) + wear = project_wear(tire, current_odometer, readings) + distance = distance_on_tire(tire, current_odometer) below = bool( tire.tread_depth_mm is not None and tire.min_tread_mm is not None and tire.tread_depth_mm <= tire.min_tread_mm ) payload = TireResponse.model_validate(tire) - payload.projected_km_remaining = km_left - payload.projected_wear_date = wear_date + # Same wire names as before v3.3.0. A single-value result type would + # have silently dropped `projected_wear_date`, which the tire card + # renders beside the km figure. + payload.projected_km_remaining = wear.km_remaining + payload.projected_wear_date = wear.wear_date + payload.wear_status = wear.status.value + payload.distance_km = distance.all_time_value + payload.known_distance_km = distance.known_value + payload.known_distance_since = distance.known_since + payload.distance_status = distance.status.value + # Whichever result is blocked names the periods to act on. Distance + # wins when both are: it is the more specific repair. + payload.blocking_period_ids = distance.blocking_period_ids or wear.blocking_period_ids + payload.installed_date = self._derived_installed_date(tire) payload.below_threshold = below + payload.mount_periods = [ + MountPeriodResponse.model_validate(period) + for period in sorted( + tire.mount_periods or [], key=lambda mp: (mp.mounted_on or dt.date.min, mp.id) + ) + ] if include_readings: payload.readings = [TireReadingResponse.model_validate(r) for r in readings] else: payload.readings = [] return payload - async def list_tires(self, vin: str, current_user: User) -> TireListResponse: + async def list_tires( + self, vin: str, current_user: User, include_retired: bool = False + ) -> TireListResponse: from app.services.auth import get_vehicle_or_403 vin = vin.upper().strip() try: await get_vehicle_or_403(vin, current_user, self.db) + query = select(Tire).where(Tire.vin == vin) + if not include_retired: + # A retired tire is history, not inventory. It still appears in + # analytics -- its final distance and wear are the most + # complete data the app will ever have about it -- but it does + # not belong in the list of tires you can act on. + query = query.where(Tire.retired_on.is_(None)) result = await self.db.execute( - select(Tire) - .where(Tire.vin == vin) - .options(selectinload(Tire.readings)) - .order_by(Tire.position) + query.options(selectinload(Tire.readings), selectinload(Tire.mount_periods)) + # Mounted tires first, then stored ones. `position` is + # nullable now, and a bare ORDER BY sorts NULLs FIRST on + # SQLite and LAST on PostgreSQL, so the two dialects would + # disagree about the order of a user's own tire list. + .order_by(Tire.position.is_(None), Tire.position) ) tires = result.scalars().unique().all() - responses = [self._to_response(t) for t in tires] + current_odometer = await self._current_odometer(vin) + responses = [self._to_response(t, current_odometer=current_odometer) for t in tires] return TireListResponse(tires=responses, total=len(responses)) except HTTPException: raise @@ -121,33 +469,31 @@ async def list_tires(self, vin: str, current_user: User) -> TireListResponse: ) raise HTTPException(status_code=503, detail="Database temporarily unavailable") - async def upsert_tire(self, vin: str, data: TireCreate, current_user: User) -> TireResponse: + async def create_tire(self, vin: str, data: TireCreate, current_user: User) -> TireResponse: + """Create a tire. It is NOT mounted anywhere until you mount it. + + This replaced `upsert_tire`, and the change is the release's breaking + one. Before v3.3.0 a tire WAS a corner: `POST /api/tires` carried a + `position` and upserted by `(vin, position)`, so there was no way to + own a tire that was off the vehicle -- a seasonal set had to be deleted + and re-entered every six months, taking its readings with it. + + A tire is now a thing you own. Mounting is a separate operation with + its own conflict semantics (that corner may be occupied), which is why + it cannot be folded back into a create. + + A stale client still sending `position` gets a 422 naming the field, + because `TireBase` forbids extras (D13). Pydantic's default is to + ignore unknown fields, which here would silently create a SECOND, + unmounted tire rather than updating the one at that corner. + """ from app.services.auth import get_vehicle_or_403 vin = vin.upper().strip() - if data.position not in TIRE_POSITIONS: - raise HTTPException(status_code=400, detail="Invalid tire position") try: await get_vehicle_or_403(vin, current_user, self.db, require_write=True) - result = await self.db.execute( - select(Tire) - .where(Tire.vin == vin, Tire.position == data.position) - .options(selectinload(Tire.readings)) - ) - tire = result.scalar_one_or_none() - if tire is None: - # Create: schema defaults are meaningful, so take the full model. - tire = Tire(vin=vin, **data.model_dump(exclude={"vin"})) - self.db.add(tire) - else: - # Update: only touch what the caller actually sent. A full - # model_dump wrote every field including unset defaults, so - # re-saving a position erased brand, model, size and DOT code - # and reset the custom wear threshold. - for key, value in data.model_dump( - exclude={"vin", "position"}, exclude_unset=True - ).items(): - setattr(tire, key, value) + tire = Tire(vin=vin, **data.model_dump(exclude={"vin"})) + self.db.add(tire) await self.db.commit() # Re-query rather than refresh(attribute_names=["readings"]). # updated_at is server-side onupdate=func.now(), so the flush leaves @@ -155,11 +501,12 @@ async def upsert_tire(self, vin: str, data: TireCreate, current_user: User) -> T # TireResponse to lazy-load it and the update path raised # MissingGreenlet -> 500 after the write had already committed. result = await self.db.execute( - select(Tire).where(Tire.id == tire.id).options(selectinload(Tire.readings)) + select(Tire) + .where(Tire.id == tire.id) + .options(selectinload(Tire.readings), selectinload(Tire.mount_periods)) ) tire = result.scalar_one() - await self._sync_low_tread_reminder(tire) - return self._to_response(tire) + return await self._reload_and_sync(tire.id, vin) except HTTPException: raise except OperationalError as e: @@ -171,6 +518,290 @@ async def upsert_tire(self, vin: str, data: TireCreate, current_user: User) -> T ) raise HTTPException(status_code=503, detail="Database temporarily unavailable") + async def mount_tire( + self, vin: str, tire_id: int, data: TireMountRequest, current_user: User + ) -> TireResponse: + """Mount a tire at a position, opening a mount period. + + **This is the only writer of `tires.position`** (D14), together with + `dismount_tire`. Both representations -- the tire's current position + and the open period's position -- are written here or neither is, so + they cannot drift. An earlier design let a period's position be edited + directly, which let the tire card and the reading history disagree + about which corner a tire was on. + + Nothing at the database level prevents two tires on one vehicle from + each holding an open period at FL: `tire_mount_periods` has no `vin`, + so the constraint cannot be written there. It is enforced here, under + the parent-tire row lock, and it has its own test because no index + will catch it. + """ + from app.services.auth import get_vehicle_or_403 + + vin = vin.upper().strip() + await get_vehicle_or_403(vin, current_user, self.db, require_write=True) + tire = await self._get_tire_for_update(vin, tire_id) + + if tire.position is not None: + raise HTTPException( + status_code=409, + detail=f"This tire is already mounted at {tire.position}. Dismount it first.", + ) + + occupant = ( + await self.db.execute( + select(Tire).where( + Tire.vin == vin, + Tire.position == data.position, + Tire.id != tire.id, + ) + ) + ).scalar_one_or_none() + if occupant is not None: + raise HTTPException( + status_code=409, + detail=f"Another tire is already mounted at {data.position}.", + ) + + # Hoisted so the period and the odometer reading cannot land on + # different dates when this runs across midnight. + mounted_on = data.mounted_on or utc_now().date() + tire.position = data.position + self.db.add( + TireMountPeriod( + tire_id=tire.id, + position=data.position, + mounted_on=mounted_on, + mounted_odometer_km=data.mounted_odometer_km, + is_assumed=False, + notes=data.notes, + ) + ) + await self._publish_odometer( + vin, mounted_on, data.mounted_odometer_km, ODOMETER_SOURCE_TIRE, tire.id + ) + await self.db.commit() + # The reminder title names the position, so mounting changes it. + return await self._reload_and_sync(tire.id, vin) + + async def dismount_tire( + self, vin: str, tire_id: int, data: TireDismountRequest, current_user: User + ) -> TireResponse: + """Take a tire off the vehicle, closing its open period.""" + from app.services.auth import get_vehicle_or_403 + + vin = vin.upper().strip() + await get_vehicle_or_403(vin, current_user, self.db, require_write=True) + tire = await self._get_tire_for_update(vin, tire_id) + + if tire.position is None: + raise HTTPException(status_code=409, detail="This tire is not mounted.") + + open_period = ( + await self.db.execute( + select(TireMountPeriod) + .where( + TireMountPeriod.tire_id == tire.id, + TireMountPeriod.dismounted_on.is_(None), + ) + .order_by(TireMountPeriod.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + dismounted_on = data.dismounted_on or utc_now().date() + tire.position = None + if open_period is not None: + open_period.dismounted_on = dismounted_on + open_period.dismounted_odometer_km = data.dismounted_odometer_km + if data.notes: + open_period.notes = data.notes + # Published even when there is no open period to close: the user still + # read that number off the dashboard. + await self._publish_odometer( + vin, dismounted_on, data.dismounted_odometer_km, ODOMETER_SOURCE_TIRE, tire.id + ) + await self.db.commit() + return await self._reload_response(tire.id, vin) + + async def create_and_mount( + self, vin: str, data: TireCreateAndMountRequest, current_user: User + ) -> TireResponse: + """Create a tire and mount it, atomically. + + The conflict semantics are the MOUNT's: if the corner is occupied the + whole operation fails and no tire is created. Doing the two calls by + hand and losing the second would leave an orphan tire behind. + """ + from app.services.auth import get_vehicle_or_403 + + vin = vin.upper().strip() + await get_vehicle_or_403(vin, current_user, self.db, require_write=True) + + occupant = ( + await self.db.execute( + select(Tire).where(Tire.vin == vin, Tire.position == data.position) + ) + ).scalar_one_or_none() + if occupant is not None: + raise HTTPException( + status_code=409, + detail=f"Another tire is already mounted at {data.position}.", + ) + + tire = Tire( + vin=vin, + position=data.position, + **data.model_dump(exclude={"vin", "position", "mounted_on", "mounted_odometer_km"}), + ) + self.db.add(tire) + await self.db.flush() + mounted_on = data.mounted_on or utc_now().date() + self.db.add( + TireMountPeriod( + tire_id=tire.id, + position=data.position, + mounted_on=mounted_on, + mounted_odometer_km=data.mounted_odometer_km, + is_assumed=False, + ) + ) + await self._publish_odometer( + vin, mounted_on, data.mounted_odometer_km, ODOMETER_SOURCE_TIRE, tire.id + ) + await self.db.commit() + return await self._reload_and_sync(tire.id, vin) + + async def rotate_tires( + self, vin: str, data: TireRotationRequest, current_user: User + ) -> TireListResponse: + """Move several tires at once, in two phases. + + **Why two phases.** `uq_tires_vin_position` is an IMMEDIATE unique + index on both dialects, so assigning one tire at a time fails the + moment a destination is still occupied -- which for a rotation is + always. An X-pattern swap collides on the very first move even though + the requested FINAL arrangement is perfectly legal. + + So: clear every affected `tires.position` and close every affected + period, FLUSH, then assign the new positions and open the new periods. + Deferring the constraint is not an option; SQLite has no + `DEFERRABLE INITIALLY DEFERRED`. + + All or nothing. A rotation that applied its valid moves and rejected + the rest would leave the vehicle in an arrangement nobody asked for. + """ + from app.services.auth import get_vehicle_or_403 + + vin = vin.upper().strip() + await get_vehicle_or_403(vin, current_user, self.db, require_write=True) + + moving_ids = [move.tire_id for move in data.moves] + tires = { + tire.id: tire + for tire in ( + await self.db.execute(select(Tire).where(Tire.id.in_(moving_ids), Tire.vin == vin)) + ) + .scalars() + .all() + } + missing = [tid for tid in moving_ids if tid not in tires] + if missing: + raise HTTPException(status_code=404, detail=f"Tire(s) not found: {missing}") + + # A destination held by a tire that is NOT part of this rotation is a + # conflict, not a swap. Checked before any write. + targets = {move.position for move in data.moves} + blockers = ( + ( + await self.db.execute( + select(Tire).where( + Tire.vin == vin, + Tire.position.in_(targets), + Tire.id.notin_(moving_ids), + ) + ) + ) + .scalars() + .all() + ) + if blockers: + occupied = sorted(t.position for t in blockers if t.position) + raise HTTPException( + status_code=409, + detail=f"Position(s) {occupied} are held by tires not in this rotation.", + ) + + when = data.rotated_on or utc_now().date() + await apply_mount_moves( + self.db, + vacate=[tires[tire_id] for tire_id in moving_ids], + assign=[(tires[move.tire_id], move.position) for move in data.moves], + when=when, + odometer_km=data.odometer_km, + notes=data.notes, + ) + + # ONE reading however many tires moved: the odometer is a fact about + # the vehicle, not about each corner. `min(moving_ids)` is only the + # marker's id and nothing reads it back -- it is order-independent, so + # a client that lists its moves differently produces the same note. + await self._publish_odometer( + vin, when, data.odometer_km, ODOMETER_SOURCE_ROTATION, min(moving_ids) + ) + await self.db.commit() + return await self.list_tires(vin, current_user) + + async def _get_tire_for_update(self, vin: str, tire_id: int) -> Tire: + """Load a tire, scoped to its vehicle, or 404.""" + tire = ( + await self.db.execute( + select(Tire) + .where(Tire.id == tire_id, Tire.vin == vin) + .options(selectinload(Tire.readings), selectinload(Tire.mount_periods)) + ) + ).scalar_one_or_none() + if tire is None: + raise HTTPException(status_code=404, detail="Tire not found") + return tire + + async def _reload_and_sync(self, tire_id: int, vin: str) -> TireResponse: + """Reload, run the low-tread reminder sync, and serialise. + + The sync has to happen on every path that can change a tire's tread or + its mounted state, not only on the reading path. A tire entered with a + tread already below its threshold is exactly the case a user needs + warned about, and the previous `upsert_tire` did sync it -- so the + create/mount split had to carry that behaviour across rather than drop + it silently. + """ + tire = ( + await self.db.execute( + select(Tire) + .where(Tire.id == tire_id) + .options(selectinload(Tire.readings), selectinload(Tire.mount_periods)) + ) + ).scalar_one() + await self._sync_low_tread_reminder(tire) + return await self._reload_response(tire_id, vin) + + async def _reload_response(self, tire_id: int, vin: str) -> TireResponse: + """Re-query and serialise. + + Re-queried rather than refreshed: `updated_at` is a server-side + onupdate, so a flush leaves it expired even with + expire_on_commit=False, and a partial refresh left TireResponse to + lazy-load it -- MissingGreenlet, a 500 after the write had committed. + """ + tire = ( + await self.db.execute( + select(Tire) + .where(Tire.id == tire_id) + .options(selectinload(Tire.readings), selectinload(Tire.mount_periods)) + ) + ).scalar_one() + return self._to_response(tire, current_odometer=await self._current_odometer(vin)) + async def update_tire( self, vin: str, tire_id: int, data: TireUpdate, current_user: User ) -> TireResponse: @@ -182,17 +813,36 @@ async def update_tire( result = await self.db.execute( select(Tire) .where(Tire.id == tire_id, Tire.vin == vin) - .options(selectinload(Tire.readings)) + .options(selectinload(Tire.readings), selectinload(Tire.mount_periods)) ) tire = result.scalar_one_or_none() if not tire: raise HTTPException(status_code=404, detail="Tire not found") - for key, value in data.model_dump(exclude_unset=True).items(): + fields = data.model_dump(exclude_unset=True) + # `tires.set_id` carries no composite FK against the tire's vin, by + # design (D6: a set is UX grouping no calculation depends on, so it + # is not worth one). That makes this the ONLY thing standing between + # a user and a tire filed under another vehicle's set, so it is + # checked before the assignment rather than after. + if fields.get("set_id") is not None: + owner = ( + await self.db.execute( + select(TireSet.id).where(TireSet.id == fields["set_id"], TireSet.vin == vin) + ) + ).scalar_one_or_none() + if owner is None: + raise HTTPException(status_code=404, detail="Tire set not found") + for key, value in fields.items(): setattr(tire, key, value) await self.db.commit() await self.db.refresh(tire) await self._sync_low_tread_reminder(tire) - return self._to_response(tire) + # WITH the odometer. Without it every edit answered as though the + # vehicle had never had a reading, so a PUT that only changed a + # brand came back reporting the tire's distance as unknown. The + # wrong figure never rendered because the client refetches, which + # is exactly why it survived. + return self._to_response(tire, current_odometer=await self._current_odometer(vin)) except HTTPException: raise except OperationalError as e: @@ -200,7 +850,64 @@ async def update_tire( logger.error("DB error updating tire %s: %s", tire_id, sanitize_for_log(e)) raise HTTPException(status_code=503, detail="Database temporarily unavailable") + async def retire_tire( + self, vin: str, tire_id: int, data: TireDismountRequest, current_user: User + ) -> TireResponse: + """Retire a tire: it comes off the vehicle and keeps everything. + + This is what a user means by "I replaced this tire", and before v3.3.0 + the only way to express it was DELETE -- which cascades through every + reading and every mount period. Shipping the mount-period model beside + an unchanged delete would mean the first thing someone does after + collecting a season of data is erase it. + + Delete still exists, for a tire entered by mistake. + """ + from app.services.auth import get_vehicle_or_403 + + vin = vin.upper().strip() + await get_vehicle_or_403(vin, current_user, self.db, require_write=True) + tire = await self._get_tire_for_update(vin, tire_id) + + if tire.retired_on is not None: + raise HTTPException(status_code=409, detail="This tire is already retired.") + + # One date for the closed period and the retirement both, so a retire + # that runs across midnight cannot record two. + retired_on = data.dismounted_on or utc_now().date() + + if tire.position is not None: + # Close the open period and free the corner, so the replacement can + # go where the old one was. + open_period = ( + await self.db.execute( + select(TireMountPeriod) + .where( + TireMountPeriod.tire_id == tire.id, + TireMountPeriod.dismounted_on.is_(None), + ) + .order_by(TireMountPeriod.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if open_period is not None: + open_period.dismounted_on = retired_on + open_period.dismounted_odometer_km = data.dismounted_odometer_km + tire.position = None + + tire.retired_on = retired_on + await self._publish_odometer( + vin, retired_on, data.dismounted_odometer_km, ODOMETER_SOURCE_TIRE, tire.id + ) + await self.db.commit() + return await self._reload_response(tire.id, vin) + async def delete_tire(self, vin: str, tire_id: int, current_user: User) -> None: + """Permanently delete a tire and everything measured about it. + + For a tire entered by mistake. To replace a worn tire, RETIRE it: this + cascades through `tire_readings` and `tire_mount_periods`. + """ from app.services.auth import get_vehicle_or_403 vin = vin.upper().strip() @@ -209,6 +916,32 @@ async def delete_tire(self, vin: str, tire_id: int, current_user: User) -> None: tire = result.scalar_one_or_none() if not tire: raise HTTPException(status_code=404, detail="Tire not found") + + # Detach the reminders first, in the same transaction as the delete. + # The composite FK `(tire_id, vin) -> tires(id, vin)` carries NO + # ON DELETE action: a referential action applies to every column in the + # FK, so SET NULL would try to null `vin` as well -- and `vin` is NOT + # NULL, which makes SQLite reject the delete outright and retiring a + # tire impossible. Measured. Nulling `tire_id` here keeps the reminder + # as history and makes it inert: the sync never adopts a row whose + # `tire_id` is null. + await self.db.execute( + update(Reminder) + .where(Reminder.tire_id == tire_id, Reminder.vin == vin) + .values(tire_id=None, source=None) + ) + # The odometer readings this tire published. Nothing cascades them: + # `odometer_records` carries a FK for fuel-sourced rows only. A tire + # entered with a typo'd odometer and then deleted would otherwise leave + # the typo behind as the vehicle's latest reading, where it poisons + # every mileage reminder the vehicle has. Marker-exact, so a manual row + # is never touched -- and a row some LATER sync took ownership of + # carries that source's marker now, not this tire's. + await self.db.execute( + delete(OdometerRecord) + .where(OdometerRecord.vin == vin) + .where(OdometerRecord.notes == auto_sync_marker(ODOMETER_SOURCE_TIRE, tire_id)) + ) await self.db.delete(tire) await self.db.commit() @@ -227,7 +960,7 @@ async def add_reading( result = await self.db.execute( select(Tire) .where(Tire.id == tire_id, Tire.vin == vin) - .options(selectinload(Tire.readings)) + .options(selectinload(Tire.readings), selectinload(Tire.mount_periods)) ) tire = result.scalar_one_or_none() if not tire: @@ -271,14 +1004,24 @@ async def add_reading( tire.tread_depth_mm = data.tread_depth_mm if data.pressure_kpa is not None: tire.pressure_kpa = data.pressure_kpa + # A reading's odometer is vehicle context by this schema's own + # words, not an observation of the tire. Without publishing it the + # five writers above make things WORSE for someone who only records + # readings: mounting gives the open period an upper bound equal to + # its own start, and the card reports a confident "0 km" instead of + # admitting it does not know. + await self._publish_odometer( + vin, data.recorded_at, data.odometer_km, ODOMETER_SOURCE_TIRE, tire.id + ) await self.db.commit() await self.db.refresh(tire) result = await self.db.execute( - select(Tire).where(Tire.id == tire_id).options(selectinload(Tire.readings)) + select(Tire) + .where(Tire.id == tire_id) + .options(selectinload(Tire.readings), selectinload(Tire.mount_periods)) ) tire = result.scalar_one() - await self._sync_low_tread_reminder(tire) - return self._to_response(tire) + return await self._reload_and_sync(tire.id, vin) except HTTPException: raise except OperationalError as e: @@ -327,9 +1070,24 @@ async def _sync_low_tread_reminder(self, tire: Tire) -> None: if below and existing is None: due = utc_now().date() - km_left, wear_date = _project_wear(list(tire.readings or []), tire.min_tread_mm) + # `project_wear` sorts its own readings now. The old call passed + # them unsorted, so `newer` was the OLDEST reading, `tread_delta` + # came out negative, and this projection has been silently absent + # from every low-tread reminder ever raised. + wear = project_wear(tire, await self._current_odometer(tire.vin)) + km_left, wear_date = wear.km_remaining, wear.wear_date reminder = Reminder( vin=tire.vin, + # Which tire, and that WE made this. The sync never adopts a + # row whose `source` or `tire_id` is null, so a reminder a + # human wrote with the same title is left alone, and a reminder + # whose tire has been deleted becomes inert rather than + # attaching itself to the next tire at that corner. + tire_id=tire.id, + source="low_tread", + tread_depth_mm=tire.tread_depth_mm, + tread_threshold_mm=tire.min_tread_mm, + projected_distance_km=km_left, title=title, reminder_type="date" if wear_date is None else "both", due_date=wear_date or due, diff --git a/backend/app/services/tire_set_service.py b/backend/app/services/tire_set_service.py new file mode 100644 index 00000000..4ddb726a --- /dev/null +++ b/backend/app/services/tire_set_service.py @@ -0,0 +1,314 @@ +"""Tire sets: naming a group of tires, and fitting it in one action. + +D6 of the mount-period design says sets are UX grouping only, and this module +holds that line: nothing here computes distance, wear or position history. +`tire_mount_periods` remains the single source for all three, and a set is a +label plus one convenience operation. + +The convenience is the whole point, though. Swapping a seasonal set by hand is +eight operations -- four dismounts and four mounts -- each carrying an odometer +the user has to retype, and each of which can be got wrong independently. Here +it is one call with one odometer. +""" + +from __future__ import annotations + +import logging + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.tire import Tire, TireSet +from app.models.user import User +from app.schemas.tire import ( + TireListResponse, + TireSetCreate, + TireSetListResponse, + TireSetMountRequest, + TireSetResponse, + TireSetUpdate, +) +from app.services.tire_service import ( + ODOMETER_SOURCE_SET, + TireService, + apply_mount_moves, +) +from app.utils.datetime_utils import utc_now +from app.utils.logging_utils import sanitize_for_log +from app.utils.odometer_sync import sync_odometer_from_record + +logger = logging.getLogger(__name__) + + +def remembered_position(tire: Tire) -> str | None: + """The corner this tire was last on, or None if it has never been fitted. + + This is what makes a seasonal swap one action rather than a form: the + periods already record where each tire sat, so asking the user again would + be asking them to retype something the app knows. + + Read from the HIGHEST-id period rather than the latest `mounted_on`. + Periods are append-only, so the highest id is the most recently RECORDED + one, and a user entering last winter's history after the fact must not have + that backfill outrank the mount they did this morning. + + Returns None for a tire with no periods at all -- a set entered straight + into storage and never fitted. That is a refusal, not a corner to guess. + """ + if tire.position is not None: + return tire.position + periods = list(tire.mount_periods or []) + if not periods: + return None + return max(periods, key=lambda period: period.id).position + + +def _describe(tire: Tire) -> str: + """Name a tire in an error a user has to act on. + + The brand is what is printed on the sidewall and on the card; the id is a + fallback for a tire entered with nothing but a tread depth. + """ + return tire.brand or f"Tire #{tire.id}" + + +class TireSetService: + """CRUD for sets, plus the one operation that justifies them.""" + + def __init__(self, db: AsyncSession): + self.db = db + + @staticmethod + def _to_response(tire_set: TireSet) -> TireSetResponse: + """Serialise a set with its live membership. + + Retired tires are excluded from BOTH counts. A retired tire is history + rather than inventory, so a set that still listed it would offer to fit + a tire the user has thrown away, and its "3 of 4 fitted" would never + reach 4. + """ + members = [tire for tire in (tire_set.tires or []) if tire.retired_on is None] + return TireSetResponse( + id=tire_set.id, + vin=tire_set.vin, + name=tire_set.name, + notes=tire_set.notes, + created_at=tire_set.created_at, + tire_ids=sorted(tire.id for tire in members), + mounted_count=sum(1 for tire in members if tire.position is not None), + ) + + async def _get_set(self, vin: str, set_id: int) -> TireSet: + """Load a set, scoped to its vehicle, or 404. + + Scoping every read through here is what stops one vehicle's set being + renamed, deleted or fitted through another vehicle's URL. There is no + database constraint doing it. + """ + tire_set = ( + await self.db.execute( + select(TireSet) + .where(TireSet.id == set_id, TireSet.vin == vin) + .options(selectinload(TireSet.tires).selectinload(Tire.mount_periods)) + ) + ).scalar_one_or_none() + if tire_set is None: + raise HTTPException(status_code=404, detail="Tire set not found") + return tire_set + + async def list_sets(self, vin: str, current_user: User) -> TireSetListResponse: + """Every set for a vehicle, oldest first.""" + from app.services.auth import get_vehicle_or_403 + + vin = vin.upper().strip() + await get_vehicle_or_403(vin, current_user, self.db) + rows = ( + ( + await self.db.execute( + select(TireSet) + .where(TireSet.vin == vin) + .options(selectinload(TireSet.tires)) + .order_by(TireSet.id) + ) + ) + .scalars() + .all() + ) + sets = [self._to_response(row) for row in rows] + return TireSetListResponse(sets=sets, total=len(sets)) + + async def create_set( + self, vin: str, data: TireSetCreate, current_user: User + ) -> TireSetResponse: + """Name a new, empty set. + + Empty on purpose: membership is written from the tire side, so there is + exactly one writer for it. + """ + from app.services.auth import get_vehicle_or_403 + + vin = vin.upper().strip() + await get_vehicle_or_403(vin, current_user, self.db, require_write=True) + tire_set = TireSet(vin=vin, name=data.name, notes=data.notes) + self.db.add(tire_set) + await self.db.commit() + await self.db.refresh(tire_set) + return await self._reload(vin, tire_set.id) + + async def update_set( + self, vin: str, set_id: int, data: TireSetUpdate, current_user: User + ) -> TireSetResponse: + """Rename a set, or change its notes.""" + from app.services.auth import get_vehicle_or_403 + + vin = vin.upper().strip() + await get_vehicle_or_403(vin, current_user, self.db, require_write=True) + tire_set = await self._get_set(vin, set_id) + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(tire_set, key, value) + await self.db.commit() + return await self._reload(vin, set_id) + + async def delete_set(self, vin: str, set_id: int, current_user: User) -> None: + """Delete a set. Its tires survive, ungrouped. + + `tires.set_id` is ON DELETE SET NULL rather than CASCADE, and that is + the difference between deleting a label and deleting four tires plus a + season of readings. + """ + from app.services.auth import get_vehicle_or_403 + + vin = vin.upper().strip() + await get_vehicle_or_403(vin, current_user, self.db, require_write=True) + tire_set = await self._get_set(vin, set_id) + # No explicit null-out. `TireSet.tires` is a plain one-to-many with no + # delete cascade, so SQLAlchemy de-associates the loaded collection -- + # `UPDATE tires SET set_id = NULL` -- before the DELETE, on both + # dialects and regardless of `PRAGMA foreign_keys`. The FK's ON DELETE + # SET NULL is the backstop under a raw SQL delete. An explicit loop was + # written here first and then removed: no mutation could kill it, + # because the ORM was already doing the same work. + await self.db.delete(tire_set) + await self.db.commit() + + async def mount_set( + self, vin: str, set_id: int, data: TireSetMountRequest, current_user: User + ) -> TireListResponse: + """Fit every tire in a set, each at the corner it was last on. + + All or nothing, for the same reason a rotation is: an arrangement that + applied three of four moves is one nobody asked for and one the user + would have to read back corner by corner to discover. + + Everything the incoming set displaces comes off in the same + transaction, bounded by the same odometer, so the outgoing periods are + closed rather than left open at a corner someone else now holds. + """ + from app.services.auth import get_vehicle_or_403 + + vin = vin.upper().strip() + await get_vehicle_or_403(vin, current_user, self.db, require_write=True) + tire_set = await self._get_set(vin, set_id) + + members = [tire for tire in (tire_set.tires or []) if tire.retired_on is None] + if not members: + raise HTTPException( + status_code=409, + detail=f"'{tire_set.name}' has no tires in it yet.", + ) + + destinations: dict[int, str] = {} + unplaceable: list[Tire] = [] + for tire in members: + position = remembered_position(tire) + if position is None: + unplaceable.append(tire) + else: + destinations[tire.id] = position + if unplaceable: + names = ", ".join(_describe(tire) for tire in unplaceable) + raise HTTPException( + status_code=409, + detail=( + f"These tires have never been fitted, so there is no corner to put " + f"them back on: {names}. Mount them once, then the set remembers." + ), + ) + + # Two members that were last on the same corner cannot both go back to + # it. Caught here so the message names the corner, rather than mid-write + # as an IntegrityError that names an index. + seen: dict[str, Tire] = {} + clashes: list[str] = [] + for tire in members: + position = destinations[tire.id] + if position in seen: + clashes.append(f"{position} ({_describe(seen[position])} and {_describe(tire)})") + seen[position] = tire + if clashes: + raise HTTPException( + status_code=409, + detail=( + "Two tires in this set were last on the same corner: " + + "; ".join(clashes) + + ". Mount them individually to say where each one goes." + ), + ) + + wanted = set(destinations.values()) + member_ids = {tire.id for tire in members} + displaced = ( + ( + await self.db.execute( + select(Tire).where( + Tire.vin == vin, + Tire.position.in_(wanted), + Tire.id.notin_(member_ids), + ) + ) + ) + .scalars() + .all() + ) + + # A member already sitting on its own destination is left alone. Taking + # it off and putting it straight back would close a period and open an + # identical one, splitting its history at a moment when nothing + # happened to it. + moving = [tire for tire in members if tire.position != destinations[tire.id]] + when = data.mounted_on or utc_now().date() + + await apply_mount_moves( + self.db, + vacate=[*displaced, *[tire for tire in moving if tire.position is not None]], + assign=[(tire, destinations[tire.id]) for tire in moving], + when=when, + odometer_km=data.odometer_km, + notes=data.notes, + ) + # ONE reading for the whole swap. Marked as a set fit rather than as a + # per-tire operation, so deleting any one tire in the set does not take + # the vehicle's odometer reading with it. + await sync_odometer_from_record( + self.db, + vin, + when, + data.odometer_km, + ODOMETER_SOURCE_SET, + tire_set.id, + commit=False, + ) + await self.db.commit() + logger.info( + "Fitted tire set %s (%s tires) for %s", + set_id, + len(moving), + sanitize_for_log(vin), + ) + return await TireService(self.db).list_tires(vin, current_user) + + async def _reload(self, vin: str, set_id: int) -> TireSetResponse: + """Re-read a set with its membership loaded, after a write.""" + return self._to_response(await self._get_set(vin, set_id)) diff --git a/backend/app/tasks/livelink_tasks.py b/backend/app/tasks/livelink_tasks.py index 4790b396..e152baf3 100644 --- a/backend/app/tasks/livelink_tasks.py +++ b/backend/app/tasks/livelink_tasks.py @@ -60,16 +60,31 @@ async def check_session_timeouts(): return timeout_minutes = await livelink_service.get_session_timeout_minutes() + gap_minutes = await livelink_service.get_session_gap_minutes() - # Close stale sessions + # Close stale sessions on either clock: contact loss (the device has + # gone quiet) or the drive gap (it is still talking and has not + # moved). Two settings, because "has this device gone quiet?" is not + # "was that the same drive?", and an admin must be able to fix trip + # grouping without also changing failure detection. session_service = SessionService(db) - closed = await session_service.check_session_timeouts(timeout_minutes) + closed = await session_service.check_session_timeouts( + timeout_minutes=timeout_minutes, gap_minutes=gap_minutes + ) + + # Housekeeping the live path cannot do, because both are defined by + # the ABSENCE of a payload: discard pending drives that never went + # anywhere, and finalize `awaiting` closures past the reopen window. + expired = await session_service.expire_stale_movement_state(gap_minutes=gap_minutes) + if expired: + await db.commit() if closed: logger.info( - "Closed %d stale sessions (timeout: %d minutes)", + "Closed %d stale session(s) (contact %dm / gap %dm)", len(closed), timeout_minutes, + gap_minutes, ) except Exception as e: @@ -313,19 +328,26 @@ async def finalize_pending_offlines(): pending_at = pending_at.replace(tzinfo=None) if pending_at <= cutoff: - # Grace period expired — finalize the offline transition - if device.vin: - await session_service.handle_ecu_offline( - device.vin, - device.device_id, - ) - - # Clear pending state and set ecu_status to offline - await livelink_service.clear_pending_offline(device.device_id) - await livelink_service.update_device_status( - device_id=device.device_id, - ecu_status="offline", - ) + # Grace period expired. Close the session DIRECTLY rather + # than via transition detection: the ingest routes persist + # `ecu_status='offline'` the moment it arrives, so by now + # `handle_ecu_offline` sees offline -> offline and no-ops, + # leaving the session to a contact timeout anchored on a + # `last_seen` this finalizer used to advance itself. + # + # `finalize_offline` also clears the pending movement state, + # which must happen HERE and not when the offline arrived: + # otherwise a brief WiFi drop inside the grace period + # discards the warm-up and opening-odometer samples the + # pending state exists to preserve. + await session_service.finalize_offline(device) + + # `set_device_offline`, not `update_device_status`: the + # latter stamps `last_seen = utc_now()` on every call. There + # was no contact, and fabricating one corrupts every timeout + # that reads it -- including the contact-loss clock, which + # would then measure from a moment the device never spoke. + await livelink_service.set_device_offline(device.device_id) finalized += 1 logger.info( "Finalized pending offline for device %s (grace period expired)", @@ -360,9 +382,21 @@ async def is_mqtt_enabled() -> bool: async def start_mqtt_subscriber() -> None: - """Start the MQTT subscriber if enabled.""" + """Start the MQTT subscriber if enabled and not in maintenance mode. + + The maintenance check is here rather than only in the lifespan because + `POST /api/livelink/mqtt/restart` reaches this function directly. That route + writes no telemetry of its own, so a route-level telemetry gate does not + cover it, and an admin could reopen ingest in the middle of the odometer + repair window. + """ + from app.config import settings from app.services.mqtt_subscriber import mqtt_subscriber + if settings.maintenance_mode: + logger.warning("Maintenance mode: refused to start the MQTT subscriber") + return + try: if await is_mqtt_enabled(): await mqtt_subscriber.start() diff --git a/backend/app/utils/csv_emission.py b/backend/app/utils/csv_emission.py index 6d230703..5026f5d0 100644 --- a/backend/app/utils/csv_emission.py +++ b/backend/app/utils/csv_emission.py @@ -161,6 +161,9 @@ class EmittedColumn: EMITTED_COLUMNS: Mapping[str, EmittedColumn] = { "Odometer (km)": ODOMETER_COLUMN, "Reading (km)": EmittedColumn(DISTANCE, "distance", "Reading", _DISTANCE_DECIMALS), + # A warranty's mileage cap. Added in v3.3.0: the column is unit-bearing and + # had never been exported at all, so there is no legacy spelling to alias. + "Mileage Limit (km)": EmittedColumn(DISTANCE, "distance", "Mileage Limit", _DISTANCE_DECIMALS), "Liters": VOLUME_COLUMN, "Price Per Liter": EmittedColumn(PRICE_PER_VOLUME, "volume", "Price Per Unit", _PRICE_DECIMALS), # DEF's price column has always been called `Price Per Unit`; v6 adds the diff --git a/backend/app/utils/csv_units.py b/backend/app/utils/csv_units.py index 70da189e..b31c89c2 100644 --- a/backend/app/utils/csv_units.py +++ b/backend/app/utils/csv_units.py @@ -196,6 +196,10 @@ class ColumnBinding: # The standalone odometer pair spells it `Reading (km)` / `Reading`, and also # accepts `Mileage` (it has since v3). +# A warranty's mileage cap. v3.3.0 is the first release to export it, so there +# is no legacy header to alias: the tokenised spelling is the only one. +MILEAGE_LIMIT_DISTANCE = QuantitySpec(DISTANCE, ("Mileage Limit",), ()) + READING_DISTANCE = QuantitySpec( DISTANCE, ("Reading",), (LegacyHeader("Reading"), LegacyHeader("Mileage")) ) diff --git a/backend/app/utils/db_url.py b/backend/app/utils/db_url.py new file mode 100644 index 00000000..e28d9a35 --- /dev/null +++ b/backend/app/utils/db_url.py @@ -0,0 +1,47 @@ +"""Convert an async SQLAlchemy URL to the sync driver equivalent. + +The app runs on async drivers (`asyncpg`, `aiosqlite`), but two things need a +sync engine: the migration runner, and the maintenance tools under +`backend/tools/`. This conversion was written inline in `init_db` and nowhere +else, so the tools each hardcoded `sqlite:///{path}` instead. On a PostgreSQL +instance that silently created an empty SQLite file and then failed with +`no such table`, leaving PostgreSQL deployments with no repair path. + +Matching is anchored to the scheme. The original inline version used an +unanchored substring test, which would also rewrite a driver name appearing in +a password. +""" + +from __future__ import annotations + +#: Async driver scheme -> the sync driver the migration runner and tools use. +_ASYNC_TO_SYNC: tuple[tuple[str, str], ...] = ( + ("postgresql+asyncpg", "postgresql+psycopg2"), + ("sqlite+aiosqlite", "sqlite"), +) + + +def to_sync_url(url: str) -> str: + """Return ``url`` with any async driver replaced by its sync counterpart. + + Idempotent: a URL that already names a sync driver is returned unchanged, + so callers may apply it without first checking. + + Args: + url: A SQLAlchemy database URL, async or sync. + + Returns: + The same URL with a sync driver. + + Raises: + ValueError: If ``url`` is empty or whitespace. Passing one on to + ``create_engine`` produces a failure that names neither the caller + nor the missing configuration. + """ + if not url or not url.strip(): + raise ValueError("database URL is empty; nothing to connect to") + + for async_scheme, sync_scheme in _ASYNC_TO_SYNC: + if url.startswith(async_scheme): + return sync_scheme + url[len(async_scheme) :] + return url diff --git a/backend/app/utils/distance_counters.py b/backend/app/utils/distance_counters.py new file mode 100644 index 00000000..7b207651 --- /dev/null +++ b/backend/app/utils/distance_counters.py @@ -0,0 +1,154 @@ +"""Telemetry keys whose increase measures distance travelled. + +An odometer is the obvious source and on some hardware it is the worst one +available. A 2019 Mirage, measured over 165 days of its own stored telemetry: + + ODOMETER 17,052 samples, 149 changes, ~23.75 km per step + 31-DISTANCESINCECODECLEAR 11,027 samples, 1,712 changes, ~1.00 km per step + +Both agree with the vehicle's recorded mileage in aggregate, to +0.3% and +1.0%. +They differ only in resolution, and resolution decides whether a drive gets a +distance at all: a counter that ticks 149 times in five months can attribute +distance to at most 149 drives, and the average trip is shorter than one of its +steps. That instance held 2,781 zero-distance sessions against 172 with a +number, and the 172 were wrong as well -- each had swallowed a ~24 km step that +mostly accrued while the vehicle was parked. + +So the odometer is not privileged here. ``_calculate_session_distance`` reads +every source in the window and takes the one that resolves finest. + +WHY THE PID PREFIX IS REQUIRED, AND THE ODOMETER SET IS NOT REUSED +------------------------------------------------------------------ +``31-DISTANCESINCECODECLEAR`` is kilometres because SAE J1979 defines PID 0x31 +as kilometres. The same NAME without a prefix is a WiCAN autopid -- a +user-defined CAN expression reading the dash, which on a US-market car is +miles. `app/utils/odometer_units.py` turns on exactly this distinction, but the +odometer survives the ambiguity only because ``LiveLinkDevice.odometer_unit`` +lets a device declare its units. Nothing declares units for a distance counter, +so an unprefixed key is refused rather than assumed metric; reading a miles +counter as kilometres under-reports by 38%. + +This also means the two sets must stay DISJOINT. A distance counter may supply +``distance_km`` and must never supply ``start_odometer`` / ``end_odometer``: +PID 0x31 resets to zero when a technician clears a code, so stamping it into +the odometer columns would report a vehicle with 3,000 km on it. The counters +are enumerated here rather than folded into ``_ODOMETER_BARE_KEYS`` so that +mistake requires a deliberate edit to two files. +""" + +from __future__ import annotations + +from collections.abc import Container, Mapping, Sequence +from dataclasses import dataclass + +from app.utils.odometer_units import ( + OBD2_PID_PREFIX_RE, + bare_param_key, + is_odometer_param_key, +) + +#: Standard SAE J1979 cumulative-distance PIDs, prefix stripped, in kilometres. +#: Deliberately an exact set rather than a "DISTANCE" substring scan, which +#: would swallow `DISTANCE_TO_EMPTY` (a fuel-range estimate that FALLS as the +#: vehicle is driven) and every trip-computer field a firmware exposes. +#: +#: PID 0x21, `DISTANCEMILON`, is excluded even though it is a standard metric +#: distance PID with the same 1 km resolution. It counts only the distance +#: driven with the malfunction light lit, so it is a SUBSET of the distance +#: travelled, and a subset that begins mid-drive when a fault appears. On a +#: vehicle whose odometer never ticks within a drive it would out-resolve the +#: odometer and win, then report the 5 km since the light came on as the length +#: of a 12 km trip. A source has to measure the whole journey to be ranked +#: against sources that do. +_DISTANCE_COUNTER_BARE_KEYS = frozenset( + { + "DISTANCESINCECODECLEAR", # PID 0x31, km since diagnostic codes cleared + } +) + + +def is_distance_counter_param_key(param_key: str) -> bool: + """True if ``param_key`` is a standard cumulative-distance PID. + + Requires the two-hex-digit PID prefix: that prefix is the only evidence + that the value is metric, and there is no per-device override to correct a + wrong guess with. An odometer is never a distance counter -- see the module + docstring for why the sets stay disjoint. + """ + upper = param_key.upper() + if not OBD2_PID_PREFIX_RE.match(upper): + return False + return bare_param_key(param_key) in _DISTANCE_COUNTER_BARE_KEYS + + +def is_distance_source_param_key(param_key: str) -> bool: + """True if this key's increase measures distance travelled, in kilometres. + + The union of odometers and standard distance counters. Both read as + kilometres out of ``vehicle_telemetry``, by two different routes: + ``TelemetryService._normalize_odometer_units`` converts odometer values at + ingest, while a prefixed distance counter is stored raw and is already + metric by specification. + """ + return is_odometer_param_key(param_key) or is_distance_counter_param_key(param_key) + + +@dataclass(frozen=True) +class TravelledSpan: + """What one distance source says about one window of telemetry. + + ``low``/``high`` are the readings at the edges, which is what the odometer + columns want: they answer "what did the clock read", not "how far did it + move". ``distance_km`` is the sum of positive steps, which is what the + vehicle travelled. ``steps`` is how many times the source changed, and is + the resolution measure :func:`select_distance_source` ranks on. + """ + + low: float + high: float + steps: int + distance_km: float + + +def measure_travelled(values: Sequence[float]) -> TravelledSpan: + """Reduce one source's readings, in time order, to what they say about a drive. + + Distance is the sum of POSITIVE steps rather than ``max - min``. For a + monotonic source the two are identical, so an odometer measures exactly what + it always did; they part company on a counter that resets, where a code + clear mid-window makes the span read 806 km on a 15 km drive. + + A source seen once has no steps and no distance. That is the honest answer + and not a missing value: one reading proves the vehicle was somewhere, never + that it went anywhere. + """ + distance = 0.0 + steps = 0 + low = high = values[0] + for previous, current in zip(values, values[1:], strict=False): + if current > previous: + distance += current - previous + steps += 1 + low = min(low, current) + high = max(high, current) + return TravelledSpan(low=low, high=high, steps=steps, distance_km=distance) + + +def select_distance_source( + spans: Mapping[str, TravelledSpan], odometer_keys: Container[str] +) -> str | None: + """Pick the source whose readings describe this window most finely. + + Most steps wins, because steps are resolution: a counter that ticks twelve + times in a window can place distance inside it, and one that ticks once can + only say a step happened somewhere. A source that never ticks describes the + drive not at all. + + **An odometer wins its own ties**, so another source displaces it only by + resolving STRICTLY finer here. That is what keeps this change additive: a + device whose odometer was already adequate computes what it always did, and + nothing quietly restates a session that was being measured correctly. + """ + if not spans: + return None + return max(spans, key=lambda key: (spans[key].steps, key in odometer_keys)) diff --git a/backend/app/utils/movement_keys.py b/backend/app/utils/movement_keys.py new file mode 100644 index 00000000..e2ca3870 --- /dev/null +++ b/backend/app/utils/movement_keys.py @@ -0,0 +1,100 @@ +"""Which telemetry keys carry the signals that prove a vehicle moved. + +A drive session used to open on *contact* -- any sign the dongle could reach the +broker. A parked WiCAN publishes a battery-voltage heartbeat roughly every 95 +minutes, so 83% of recorded sessions were a heartbeat rather than a drive. +Opening on *movement* instead needs a reliable answer to "is this key a speed +reading?", across every naming convention the four ingest paths produce. + +WHY NOT REUSE ``is_odometer_param_key``'s TREATMENT +-------------------------------------------------- +That helper strips the OBD2 PID prefix and compares against a bare-name set, +which works for odometer only by coincidence: ``A6-ODOMETER`` strips to +``ODOMETER``, which *is* the bare name. Speed and RPM do not have that property. + + 0D-VEHICLESPEED strips to VEHICLESPEED which is NOT "SPEED" + 0C-ENGINERPM strips to ENGINERPM which is NOT "ENGINE_RPM" + +A faithful port would return False for exactly the PID-prefixed keys that the +standard-PID WiCAN firmware emits, so no session would open on that hardware at +all -- silently, because "no movement detected" and "key not recognised" look +identical from outside. The alias sets below are therefore explicit, and +``test_movement_keys.py`` asserts every historically-observed spelling. + +Torque needs no special case: ``torque_pid_map`` maps ``k0d -> SPEED`` and +``k0c -> ENGINE_RPM``, deliberately chosen to match these names. +""" + +from __future__ import annotations + +from app.utils.odometer_units import bare_param_key + +#: Speed aliases, with any OBD2 PID prefix stripped. ``VEHICLESPEED`` is what +#: ``0D-VehicleSpeed`` becomes; ``SPEED`` is the bare WiCAN autopid and the +#: Torque mapping. Both spellings have been observed in production. +_SPEED_BARE_KEYS = frozenset({"SPEED", "VEHICLESPEED", "VEHICLE_SPEED"}) + +#: RPM aliases. ``ENGINERPM`` is ``0C-EngineRPM`` stripped; ``ENGINE_RPM`` is +#: the canonical form ``canonical_param_key`` produces from ``Engine RPM``. +_RPM_BARE_KEYS = frozenset({"RPM", "ENGINERPM", "ENGINE_RPM"}) + + +#: The standard SAE J1979 PIDs that carry these readings. Used to generate the +#: prefixed spellings for the aggregate reader's SQL ``IN`` list, so that list +#: is derived from the alias sets above rather than hand-copied beside them. +_SPEED_PID_PREFIXES = ("0D",) +_RPM_PID_PREFIXES = ("0C",) + +#: Keys a PARKED vehicle publishes on its own, prefix stripped. Read through +#: :func:`is_parked_heartbeat_key`; the set is public only because +#: ``LiveLinkService`` matches it in SQL, where a Python predicate cannot go. +PARKED_HEARTBEAT_KEYS = frozenset({"BATTERY_VOLTAGE"}) + + +def _candidates(bare_keys: frozenset[str], prefixes: tuple[str, ...]) -> list[str]: + """Every spelling of ``bare_keys``, bare and PID-prefixed, sorted.""" + return sorted(bare_keys | {f"{p}-{k}" for p in prefixes for k in bare_keys}) + + +def speed_param_key_candidates() -> list[str]: + """Every speed spelling, for a SQL ``IN`` list matched against ``upper()``. + + The aggregate reader cannot call :func:`is_speed_param_key` -- it matches in + SQL to avoid loading a window's telemetry into Python -- so it needs the set + enumerated. Generating it here rather than writing a second list in + ``session_service`` is what keeps "a key that can open a session" and "a key + the aggregates can read" the same set; ``test_movement_keys.py`` asserts + both directions. + """ + return _candidates(_SPEED_BARE_KEYS, _SPEED_PID_PREFIXES) + + +def rpm_param_key_candidates() -> list[str]: + """Every RPM spelling, for a SQL ``IN`` list matched against ``upper()``.""" + return _candidates(_RPM_BARE_KEYS, _RPM_PID_PREFIXES) + + +def is_speed_param_key(param_key: str) -> bool: + """True if ``param_key`` names a road-speed reading.""" + return bare_param_key(param_key) in _SPEED_BARE_KEYS + + +def is_rpm_param_key(param_key: str) -> bool: + """True if ``param_key`` names an engine-RPM reading.""" + return bare_param_key(param_key) in _RPM_BARE_KEYS + + +def is_parked_heartbeat_key(param_key: str) -> bool: + """True if ``param_key`` is something a PARKED vehicle publishes on its own. + + A batch containing nothing else is a heartbeat, not a vehicle whose movement + went unread, and the difference is the whole content of the "this device's + movement is unreadable" warning: without it that warning fires for every + parked dongle on every instance, which is how it came to name an entire + fleet on the first boot after migration 098. + + A predicate rather than the bare set because both callers would otherwise + re-derive the normalisation, and one of them already forgot the PID-prefix + strip that every other predicate here applies. + """ + return bare_param_key(param_key) in PARKED_HEARTBEAT_KEYS diff --git a/backend/app/utils/odometer_sync.py b/backend/app/utils/odometer_sync.py index f36c55b9..9dfe494e 100644 --- a/backend/app/utils/odometer_sync.py +++ b/backend/app/utils/odometer_sync.py @@ -16,6 +16,17 @@ from app.models import OdometerRecord +def auto_sync_marker(source_type: str, source_id: int) -> str: + """The note that marks an odometer row as owned by one source record. + + Written here and matched by the cleanup paths that remove a synced row when + its source is deleted. Those paths used to hardcode the format string, so + the writer and the matcher were one edit apart from silently disagreeing -- + and a mismatch does not fail loudly, it just orphans the row. + """ + return f"[AUTO-SYNC from {source_type} #{source_id}]" + + async def sync_odometer_from_record( db: AsyncSession, vin: str, @@ -59,7 +70,7 @@ async def sync_odometer_from_record( ) existing = result.scalars().first() - auto_sync_marker = f"[AUTO-SYNC from {source_type} #{source_id}]" + marker = auto_sync_marker(source_type, source_id) # Migration 055 added odometer_records.fuel_record_id with ON DELETE # CASCADE for fuel-sourced rows. Set it when source is 'fuel' so the @@ -73,7 +84,7 @@ async def sync_odometer_from_record( if is_auto_synced or is_livelink: existing.odometer_km = odometer_km - existing.notes = auto_sync_marker + existing.notes = marker existing.source = source_type existing.fuel_record_id = fk_value if commit: @@ -88,7 +99,7 @@ async def sync_odometer_from_record( vin=vin, date=date, odometer_km=odometer_km, - notes=auto_sync_marker, + notes=marker, source=source_type, fuel_record_id=fk_value, ) diff --git a/backend/app/utils/odometer_units.py b/backend/app/utils/odometer_units.py index 7e6d4dbe..0e965afc 100644 --- a/backend/app/utils/odometer_units.py +++ b/backend/app/utils/odometer_units.py @@ -49,9 +49,20 @@ _ODOMETER_BARE_KEYS = frozenset({"ODOMETER", "ODO", "MILEAGE", "TOTAL_DISTANCE", "DISTANCE_TOTAL"}) +def bare_param_key(param_key: str) -> str: + """Uppercase ``param_key`` with any two-hex-digit OBD2 PID prefix removed. + + The one normalisation every key-class predicate in this codebase applies + before matching. Written out separately in three modules until it drifted + into being three things that merely looked alike; a change to the prefix + shape (three-digit PIDs, say) now has one place to land. + """ + return OBD2_PID_PREFIX_RE.sub("", param_key.upper()) + + def is_odometer_param_key(param_key: str) -> bool: """True if ``param_key`` names an odometer, prefixed or bare.""" - return OBD2_PID_PREFIX_RE.sub("", param_key.upper()) in _ODOMETER_BARE_KEYS + return bare_param_key(param_key) in _ODOMETER_BARE_KEYS def infer_odometer_unit(param_key: str) -> str: diff --git a/backend/tests/integration/routes/test_dashboard.py b/backend/tests/integration/routes/test_dashboard.py index 57801033..9d906c21 100644 --- a/backend/tests/integration/routes/test_dashboard.py +++ b/backend/tests/integration/routes/test_dashboard.py @@ -721,7 +721,12 @@ async def test_fleet_health_next_due_excludes_completed_and_undated( title="Done early", reminder_type="date", due_date=today + timedelta(days=1), - status="completed", + # 'done', not 'completed': check_reminder_status has always + # been (pending, done, dismissed) in the migrated schema, and + # nothing in the app writes "completed". This fixture only + # passed because the ORM declared no CHECK, so create_all + # databases accepted a value production rejects. + status="done", ), # No due_date and no due_mileage -> not a candidate at all. Reminder( diff --git a/backend/tests/integration/routes/test_export_schema_version.py b/backend/tests/integration/routes/test_export_schema_version.py index 8c114abc..0562871e 100644 --- a/backend/tests/integration/routes/test_export_schema_version.py +++ b/backend/tests/integration/routes/test_export_schema_version.py @@ -183,34 +183,28 @@ async def test_notes_csv_unchanged_except_version_cell( assert row["Content"] == "Check tire pressure" -class TestWarrantyInsuranceCSVPreExistingBug: - """The other two dimensionless pairs cannot be pinned by a real row today. - - `export_warranties_csv` reads `record.coverage` / `record.cost` / - `record.max_claims` / `record.terms`; `WarrantyRecord` has none of those - attributes (it has `coverage_details`, and no cost/max_claims/terms - column at all). `export_insurance_csv` reads `record.premium`; - `InsurancePolicy` has `premium_amount`, not `premium`. Both are - `AttributeError`s in the row-builder, so ANY seeded row 500s before a - single CSV cell (including `units_version`) is ever written. - - Confirmed independently of this task's change (same crash reproduces - identically before and after the constant split, since the split never - touches these two functions' row-building code). Fixing the mismatch is - a data-model question (drop the promised columns from the CSV, or add - them to the model via a migration), not a mechanical rename, so it is - out of scope for Task 1, whose only job is splitting the schema-version - constant. Documented here rather than filed as a GitHub issue, per this - project's convention of surfacing findings in the task report instead of - stockpiling issues; flagged prominently in the Task 1 report for the - phase's reviewer. - - These two `AttributeError` matches are the actual `str(exc)` this - project's test container raised (captured while designing this test), - not a guess at the failure mode. +class TestWarrantyInsuranceCSVSchemaVersion: + """The other two dimensionless pairs, pinned by a real row. + + These two tests were originally written inverted, as + `TestWarrantyInsuranceCSVPreExistingBug`, asserting + `pytest.raises(AttributeError, match="coverage")` and `match="premium"`. + They passed **because** the export was broken: `export_warranties_csv` read + `record.coverage` / `cost` / `deductible` / `max_claims` / `terms` and + `export_insurance_csv` read `record.premium`, none of which exist on their + models, so any seeded row 500'd before a single CSV cell was written. + + That was the honest thing to do at the time -- the fix was a data-model + question, out of scope for a task that only split a constant, and recording + it as an executable assertion beat filing an issue. v3.3.0 fixes the + exports, so the assertions are inverted here rather than deleted: this is a + deliberate inversion, not someone removing failing tests. + + See also `test_warranty_insurance_tax_csv.py`, which covers the round trip + and the tax importer that this pair never reached. """ - async def test_warranty_csv_crashes_on_seeded_row_pre_existing_bug( + async def test_warranty_csv_emits_the_schema_version( self, client: AsyncClient, auth_headers, test_user, db_session ): from app.models.warranty import WarrantyRecord @@ -227,10 +221,14 @@ async def test_warranty_csv_crashes_on_seeded_row_pre_existing_bug( ) await db_session.commit() - with pytest.raises(AttributeError, match="coverage"): - await client.get(f"/api/export/vehicles/{vin}/warranties/csv", headers=auth_headers) + response = await client.get( + f"/api/export/vehicles/{vin}/warranties/csv", headers=auth_headers + ) + assert response.status_code == 200, response.text + rows = list(csv.DictReader(io.StringIO(response.text))) + assert rows[0]["units_version"] == "6" - async def test_insurance_csv_crashes_on_seeded_row_pre_existing_bug( + async def test_insurance_csv_emits_the_schema_version( self, client: AsyncClient, auth_headers, test_user, db_session ): from app.models.insurance import InsurancePolicy @@ -249,8 +247,12 @@ async def test_insurance_csv_crashes_on_seeded_row_pre_existing_bug( ) await db_session.commit() - with pytest.raises(AttributeError, match="premium"): - await client.get(f"/api/export/vehicles/{vin}/insurance/csv", headers=auth_headers) + response = await client.get( + f"/api/export/vehicles/{vin}/insurance/csv", headers=auth_headers + ) + assert response.status_code == 200, response.text + rows = list(csv.DictReader(io.StringIO(response.text))) + assert rows[0]["units_version"] == "6" class TestCSVSchemaVersionAlsoAppliesToUnitBearingPairs: diff --git a/backend/tests/integration/routes/test_import_row_isolation.py b/backend/tests/integration/routes/test_import_row_isolation.py new file mode 100644 index 00000000..d6aeffcc --- /dev/null +++ b/backend/tests/integration/routes/test_import_row_isolation.py @@ -0,0 +1,88 @@ +"""One bad row must not take the whole import down with it. + +Each importer wraps its row in `try/except Exception` and records a per-row +error, but the `db.commit()` that actually writes is OUTSIDE that loop. So a +constraint violation is not raised where the handler can see it: it surfaces at +commit, escapes the route, and returns **500** -- discarding every valid row in +the file along with the bad one. + +Nobody hit this before v3.3.0 for warranty, insurance or tax, because those +constructors raised `TypeError` on nonexistent kwargs first and every row failed +early inside the try. Fixing the kwargs let rows reach the database for the +first time, and exposed the real error path underneath. + +A savepoint per row puts the write back inside the handler, so a row that +violates a CHECK is one reported row and the rest of the file still imports. +""" + +from io import BytesIO + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +class TestOneBadRowDoesNotFailTheFile: + async def test_an_invalid_warranty_type_is_a_row_error_not_a_500( + self, client: AsyncClient, auth_headers, test_vehicle + ): + """`Roadside` is not in the `check_warranty_type` vocabulary. + + The good row above it must still import. + """ + csv_content = ( + "Provider,Type,Coverage Details,Start Date,End Date,Notes\n" + "Good Co,Extended,covered,2024-01-01,2029-01-01,fine\n" + "Bad Co,Roadside,covered,2024-01-01,2025-01-01,bad type\n" + ) + response = await client.post( + f"/api/import/vehicles/{test_vehicle['vin']}/warranties/csv", + headers=auth_headers, + files={"file": ("w.csv", BytesIO(csv_content.encode()), "text/csv")}, + ) + + assert response.status_code == 200, response.text + data = response.json() + assert data["success_count"] == 1, data + assert data["error_count"] == 1, data + assert any("3" in e for e in data["errors"]), data["errors"] + + async def test_an_invalid_policy_type_is_a_row_error_not_a_500( + self, client: AsyncClient, auth_headers, test_vehicle + ): + csv_content = ( + "Provider,Policy Number,Type,Start Date,End Date,Premium,Premium Frequency," + "Deductible,Coverage Limits,Notes\n" + "Good,P1,Liability,2026-01-01,2027-01-01,10.00,Monthly,100.00,100/300,ok\n" + "Bad,P2,Spaceship,2026-01-01,2027-01-01,10.00,Monthly,100.00,100/300,bad\n" + ) + response = await client.post( + f"/api/import/vehicles/{test_vehicle['vin']}/insurance/csv", + headers=auth_headers, + files={"file": ("i.csv", BytesIO(csv_content.encode()), "text/csv")}, + ) + + assert response.status_code == 200, response.text + data = response.json() + assert data["success_count"] == 1, data + assert data["error_count"] == 1, data + + async def test_a_valid_file_still_imports_every_row( + self, client: AsyncClient, auth_headers, test_vehicle + ): + """Guards the guard: a savepoint that always rolled back would pass + both tests above while importing nothing.""" + csv_content = ( + "Provider,Type,Coverage Details,Start Date,End Date,Notes\n" + "Alpha,Extended,a,2024-02-01,2029-02-01,x\n" + "Beta,Corrosion,b,2024-03-01,2029-03-01,y\n" + ) + response = await client.post( + f"/api/import/vehicles/{test_vehicle['vin']}/warranties/csv", + headers=auth_headers, + files={"file": ("w.csv", BytesIO(csv_content.encode()), "text/csv")}, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["success_count"] == 2, data + assert data["error_count"] == 0, data diff --git a/backend/tests/integration/routes/test_livelink_admin.py b/backend/tests/integration/routes/test_livelink_admin.py index 8c4729f7..333144d9 100644 --- a/backend/tests/integration/routes/test_livelink_admin.py +++ b/backend/tests/integration/routes/test_livelink_admin.py @@ -100,6 +100,11 @@ async def test_list_devices_empty(self, client: AsyncClient, auth_headers): with patch("app.routes.livelink_admin.LiveLinkService") as mock_service_class: mock_service = MagicMock() mock_service.list_devices = AsyncMock(return_value=[]) + # The route asks which devices report telemetry but nothing readable + # as movement, so the settings page can name them. Stubbed because + # the whole service is replaced here; the predicate itself is + # covered in tests/unit/services/test_movement_unreadable_devices.py. + mock_service.movement_unreadable_device_ids = AsyncMock(return_value=set()) mock_service_class.return_value = mock_service response = await client.get("/api/livelink/devices", headers=auth_headers) diff --git a/backend/tests/integration/routes/test_livelink_vehicle.py b/backend/tests/integration/routes/test_livelink_vehicle.py index 7f7a2db1..5200900d 100644 --- a/backend/tests/integration/routes/test_livelink_vehicle.py +++ b/backend/tests/integration/routes/test_livelink_vehicle.py @@ -133,6 +133,11 @@ async def test_list_sessions_empty(self, client: AsyncClient, auth_headers, test mock_service = MagicMock() mock_service.get_vehicle_sessions = AsyncMock(return_value=[]) mock_service.get_session_count = AsyncMock(return_value=0) + # Reported alongside every list so a filtered view can say how many + # motionless sessions it is holding back. Stubbed because the whole + # service is replaced here; the counts themselves are covered in + # tests/unit/services/test_session_stationary_filter.py. + mock_service.get_stationary_session_count = AsyncMock(return_value=0) mock_service_class.return_value = mock_service response = await client.get( diff --git a/backend/tests/integration/routes/test_tire_analytics.py b/backend/tests/integration/routes/test_tire_analytics.py new file mode 100644 index 00000000..9cf46689 --- /dev/null +++ b/backend/tests/integration/routes/test_tire_analytics.py @@ -0,0 +1,182 @@ +"""`GET /api/analytics/vehicles/{vin}/tires`. + +Read-only, no migration, and deliberately thin: the tires come from +`TireService.list_tires`, so this endpoint and the tire card cannot disagree +about a distance or a projection. What it adds is the readiness block, which is +the part worth shipping on an instance that has no tire data yet. + +The unit counts have their own file (`tests/unit/services/test_tire_readiness.py`). +What is tested here is the wiring: scoping, retired inclusion, and the +vehicle-level odometer flag that makes every open period unbounded when it is +false. +""" + +from __future__ import annotations + +import datetime as dt +import uuid + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy import delete + +from app.models.odometer import OdometerRecord +from app.models.vehicle import Vehicle + +ENDPOINT = "/api/analytics/vehicles/{vin}/tires" + + +@pytest_asyncio.fixture +async def vehicle(db_session, test_user): + vin = f"TXREANL{uuid.uuid4().hex[:10].upper()}".replace("I", "X")[:17] + db_session.add( + Vehicle( + vin=vin, + user_id=test_user["id"], + nickname="Analytics Rig", + vehicle_type="Car", + year=2022, + make="Mazda", + model="CX5", + ) + ) + await db_session.commit() + yield vin + await db_session.execute(delete(Vehicle).where(Vehicle.vin == vin)) + await db_session.commit() + + +async def _mount(client: AsyncClient, headers, vin: str, position: str, **extra) -> dict: + body = {"vin": vin, "position": position, "tread_depth_mm": "8.0", **extra} + response = await client.post( + f"/api/vehicles/{vin}/tires/create-and-mount", headers=headers, json=body + ) + assert response.status_code == 201, response.text + return response.json() + + +@pytest.mark.asyncio +class TestTheEndpoint: + async def test_a_vehicle_with_no_tires_answers_empty( + self, client: AsyncClient, auth_headers, vehicle + ): + """200 and nothing, not a 404. The section is gated client-side on + `tires.length`, so the endpoint has to be safe to call for anything.""" + response = await client.get(ENDPOINT.format(vin=vehicle), headers=auth_headers) + assert response.status_code == 200, response.text + body = response.json() + assert body["tires"] == [] + assert body["readiness"]["total"] == 0 + assert body["has_odometer_record"] is False + + async def test_it_reports_the_same_figures_the_tire_card_does( + self, client: AsyncClient, auth_headers, vehicle + ): + """Not an assertion about a number, an assertion about AGREEMENT. + + A second serialisation of distance or wear that can drift from the card + is worse than no analytics page, so the two are compared field by field + rather than each being pinned to a literal. + """ + await _mount(client, auth_headers, vehicle, "FL", mounted_odometer_km="1000") + + card = await client.get(f"/api/vehicles/{vehicle}/tires", headers=auth_headers) + page = await client.get(ENDPOINT.format(vin=vehicle), headers=auth_headers) + assert page.status_code == 200, page.text + + card_tire = card.json()["tires"][0] + page_tire = page.json()["tires"][0] + for field in ( + "distance_status", + "distance_km", + "known_distance_km", + "known_distance_since", + "wear_status", + "projected_km_remaining", + "projected_wear_date", + "blocking_period_ids", + ): + assert page_tire[field] == card_tire[field], field + + async def test_the_odometer_flag_follows_the_vehicle_not_the_tire( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """Its own empty state (B3). + + An OPEN mount period's upper bound is the vehicle's latest + `OdometerRecord`, so a vehicle with none returns an unbounded distance + for every tire however complete its mount history is. The page explains + that once rather than per tire, which it can only do if it is told. + """ + before = await client.get(ENDPOINT.format(vin=vehicle), headers=auth_headers) + assert before.json()["has_odometer_record"] is False + + db_session.add(OdometerRecord(vin=vehicle, date=dt.date(2026, 3, 1), odometer_km=42000)) + await db_session.commit() + + after = await client.get(ENDPOINT.format(vin=vehicle), headers=auth_headers) + assert after.json()["has_odometer_record"] is True + + async def test_a_retired_tire_is_in_the_list_and_out_of_readiness( + self, client: AsyncClient, auth_headers, vehicle + ): + """B10, end to end. + + The default tire listing hides retired tires; this endpoint asks for + them, because a retired tire's final distance and wear are the most + complete data the app will ever hold about it. It is the readiness + counts it stays out of. + """ + keeper = await _mount(client, auth_headers, vehicle, "FL", mounted_odometer_km="1000") + goner = await _mount(client, auth_headers, vehicle, "FR", mounted_odometer_km="1000") + retired = await client.post( + f"/api/vehicles/{vehicle}/tires/{goner['id']}/retire", + headers=auth_headers, + json={"dismounted_odometer_km": "2000"}, + ) + assert retired.status_code == 200, retired.text + + body = (await client.get(ENDPOINT.format(vin=vehicle), headers=auth_headers)).json() + ids = {t["id"] for t in body["tires"]} + assert ids == {keeper["id"], goner["id"]} + assert body["readiness"]["total"] == 1 + + async def test_readiness_counts_the_tires_that_can_answer( + self, client: AsyncClient, auth_headers, vehicle + ): + """One tire that can trend and one that cannot, so the count is 1. + + Seeded rather than asserted against an empty vehicle: a count of 0 on + no data is true whatever the code does. + """ + trending = await _mount(client, auth_headers, vehicle, "FL", mounted_odometer_km="1000") + await _mount(client, auth_headers, vehicle, "FR", mounted_odometer_km="1000") + for day, tread in (("2026-01-01", "8.0"), ("2026-02-01", "7.0")): + logged = await client.post( + f"/api/vehicles/{vehicle}/tires/{trending['id']}/readings", + headers=auth_headers, + json={"recorded_at": day, "tread_depth_mm": tread, "odometer_km": "5000"}, + ) + assert logged.status_code == 201, logged.text + + readiness = (await client.get(ENDPOINT.format(vin=vehicle), headers=auth_headers)).json()[ + "readiness" + ] + assert readiness["total"] == 2 + assert readiness["can_trend"] == 1 + assert readiness["needs_second_reading"] == 1 + + async def test_an_unknown_vin_is_refused(self, client: AsyncClient, auth_headers): + """Through the access gate, not around it. + + Without one this endpoint would answer 200 with an empty summary for + any string at all, which is the shape that leaks whether a VIN exists. + + Deliberately NOT "another user's vehicle": the shared `test_user` + fixture is an admin, and `get_vehicle_or_403` grants admins every + vehicle, so that test would assert a refusal that correctly never + happens. + """ + response = await client.get(ENDPOINT.format(vin="1HGCM82633A999999"), headers=auth_headers) + assert response.status_code in (403, 404), response.text diff --git a/backend/tests/integration/routes/test_tire_lifecycle.py b/backend/tests/integration/routes/test_tire_lifecycle.py new file mode 100644 index 00000000..d9e4adcc --- /dev/null +++ b/backend/tests/integration/routes/test_tire_lifecycle.py @@ -0,0 +1,433 @@ +"""Mounting, rotating, retiring and deleting a tire. + +The distinction this file is mostly about: **retiring a tire is not deleting +it**. This release is the one that makes tire history worth keeping, and the +ordinary act of replacing a worn tire was a hard DELETE that cascaded through +every reading and every mount period. Shipping the mount-period model beside an +unchanged delete would mean the first thing a user does after collecting a +season of data is erase it. +""" + +from __future__ import annotations + +import uuid +from decimal import Decimal + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy import delete, select + +from app.models.reminder import Reminder +from app.models.tire import Tire, TireMountPeriod, TireReading +from app.models.vehicle import Vehicle + + +@pytest_asyncio.fixture +async def vehicle(db_session, test_user): + """A vehicle for this test alone, cleaned up afterwards. + + Positions are claimable once per vehicle now, so a shared VIN would make + these tests order-dependent; and leaving vehicles behind breaks a + paginated assertion in test_vehicle.py. + """ + vin = f"TIRELIFE{uuid.uuid4().hex[:9].upper()}" + db_session.add( + Vehicle( + vin=vin, + user_id=test_user["id"], + nickname="Lifecycle", + vehicle_type="Car", + year=2020, + make="Honda", + model="Accord", + ) + ) + await db_session.commit() + yield vin + await db_session.execute(delete(Vehicle).where(Vehicle.vin == vin)) + await db_session.commit() + + +async def _mount(client: AsyncClient, headers, vin: str, position: str, **extra) -> dict: + body = {"vin": vin, "position": position, "tread_depth_mm": "8.0", **extra} + response = await client.post( + f"/api/vehicles/{vin}/tires/create-and-mount", headers=headers, json=body + ) + assert response.status_code == 201, response.text + return response.json() + + +@pytest.mark.asyncio +class TestMountAndDismount: + async def test_dismounting_frees_the_corner_and_keeps_the_tire( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + tire = await _mount(client, auth_headers, vehicle, "FL") + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/dismount", + headers=auth_headers, + json={"dismounted_odometer_km": "15000"}, + ) + assert response.status_code == 200, response.text + assert response.json()["position"] is None + + # The tire still exists, with its history. + stored = (await db_session.execute(select(Tire).where(Tire.id == tire["id"]))).scalar_one() + assert stored.position is None + periods = ( + ( + await db_session.execute( + select(TireMountPeriod).where(TireMountPeriod.tire_id == tire["id"]) + ) + ) + .scalars() + .all() + ) + assert len(periods) == 1 + assert periods[0].dismounted_on is not None + + # And the corner is free for another tire. + await _mount(client, auth_headers, vehicle, "FL") + + async def test_two_tires_cannot_hold_one_corner( + self, client: AsyncClient, auth_headers, vehicle + ): + await _mount(client, auth_headers, vehicle, "FR") + response = await client.post( + f"/api/vehicles/{vehicle}/tires/create-and-mount", + headers=auth_headers, + json={"vin": vehicle, "position": "FR", "tread_depth_mm": "8.0"}, + ) + assert response.status_code == 409, response.text + + async def test_a_failed_create_and_mount_leaves_no_orphan_tire( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """The reason create-and-mount is one operation. + + A caller doing create-then-mount by hand and losing the mount would be + left with a tire it did not ask for and cannot see. + """ + await _mount(client, auth_headers, vehicle, "RL") + before = len( + (await db_session.execute(select(Tire).where(Tire.vin == vehicle))).scalars().all() + ) + response = await client.post( + f"/api/vehicles/{vehicle}/tires/create-and-mount", + headers=auth_headers, + json={"vin": vehicle, "position": "RL", "brand": "Orphan"}, + ) + assert response.status_code == 409 + after = (await db_session.execute(select(Tire).where(Tire.vin == vehicle))).scalars().all() + assert len(after) == before + assert not any(t.brand == "Orphan" for t in after) + + async def test_mounting_an_already_mounted_tire_is_refused( + self, client: AsyncClient, auth_headers, vehicle + ): + tire = await _mount(client, auth_headers, vehicle, "RR") + response = await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/mount", + headers=auth_headers, + json={"position": "SPARE"}, + ) + assert response.status_code == 409, response.text + + async def test_a_stored_tire_can_be_remounted_elsewhere( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """The seasonal-swap case the whole model exists for: a tire comes off + in autumn and goes back on in spring, keeping its history.""" + tire = await _mount(client, auth_headers, vehicle, "FL", mounted_odometer_km="1000") + await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/dismount", + headers=auth_headers, + json={"dismounted_odometer_km": "9000"}, + ) + response = await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/mount", + headers=auth_headers, + json={"position": "FR", "mounted_odometer_km": "12000"}, + ) + assert response.status_code == 200, response.text + assert response.json()["position"] == "FR" + + periods = ( + ( + await db_session.execute( + select(TireMountPeriod) + .where(TireMountPeriod.tire_id == tire["id"]) + .order_by(TireMountPeriod.id) + ) + ) + .scalars() + .all() + ) + assert [p.position for p in periods] == ["FL", "FR"] + # 8,000 km on the first period. The 3,000 km the vehicle drove while + # this tire was in storage is NOT credited to it -- that is the whole + # defect this release fixes. + assert response.json()["distance_status"] == "complete" + + +@pytest.mark.asyncio +class TestDeleteAndRetire: + async def test_deleting_a_tire_with_a_reminder_succeeds( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """The composite FK carries no ON DELETE action on purpose. + + SET NULL would try to null `vin` too -- a referential action applies to + every column in the FK -- and `vehicle_reminders.vin` is NOT NULL, so + SQLite rejects the delete outright and retiring a tire becomes + impossible. The service nulls `tire_id` explicitly instead. + """ + tire = await _mount( + client, auth_headers, vehicle, "FL", tread_depth_mm="1.0", min_tread_mm="3.0" + ) + reminders = ( + (await db_session.execute(select(Reminder).where(Reminder.vin == vehicle))) + .scalars() + .all() + ) + assert reminders, "a tire below its threshold should have raised a reminder" + + response = await client.delete( + f"/api/vehicles/{vehicle}/tires/{tire['id']}", headers=auth_headers + ) + assert response.status_code in (200, 204), response.text + + # The reminder survives as history, detached from the deleted tire. + await db_session.commit() + surviving = ( + (await db_session.execute(select(Reminder).where(Reminder.vin == vehicle))) + .scalars() + .all() + ) + assert surviving, "deleting a tire must not erase the record that it was flagged" + assert all(r.tire_id is None for r in surviving) + + async def test_retiring_keeps_every_reading_and_period( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """The ordinary act of replacing a worn tire. + + Before this release it was a hard DELETE that cascaded through the + readings and mount periods -- erasing exactly the history the mount + period model exists to collect. + """ + tire = await _mount(client, auth_headers, vehicle, "FL", mounted_odometer_km="1000") + await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/readings", + headers=auth_headers, + json={"recorded_at": "2026-03-01", "tread_depth_mm": "5.0"}, + ) + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/retire", + headers=auth_headers, + json={"dismounted_odometer_km": "20000"}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["retired_on"] is not None + assert body["position"] is None, "a retired tire is not on the vehicle" + + readings = ( + (await db_session.execute(select(TireReading).where(TireReading.tire_id == tire["id"]))) + .scalars() + .all() + ) + assert len(readings) == 1, "retiring must not erase readings" + periods = ( + ( + await db_session.execute( + select(TireMountPeriod).where(TireMountPeriod.tire_id == tire["id"]) + ) + ) + .scalars() + .all() + ) + assert len(periods) == 1 + assert periods[0].dismounted_on is not None, "retiring closes the open period" + + async def test_a_retired_tire_is_out_of_the_default_list( + self, client: AsyncClient, auth_headers, vehicle + ): + tire = await _mount(client, auth_headers, vehicle, "FL") + await _mount(client, auth_headers, vehicle, "FR") + await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/retire", + headers=auth_headers, + json={}, + ) + + listed = await client.get(f"/api/vehicles/{vehicle}/tires", headers=auth_headers) + assert listed.status_code == 200 + ids = [t["id"] for t in listed.json()["tires"]] + assert tire["id"] not in ids + + # But it is still there when asked for. + with_retired = await client.get( + f"/api/vehicles/{vehicle}/tires?include_retired=true", headers=auth_headers + ) + assert with_retired.status_code == 200 + assert tire["id"] in [t["id"] for t in with_retired.json()["tires"]] + + async def test_retiring_frees_the_corner(self, client: AsyncClient, auth_headers, vehicle): + """The replacement goes where the old one was.""" + tire = await _mount(client, auth_headers, vehicle, "FL") + await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/retire", + headers=auth_headers, + json={}, + ) + await _mount(client, auth_headers, vehicle, "FL") + + +@pytest.mark.asyncio +class TestRotation: + """A rotation moves several tires at once, and the order matters. + + `uq_tires_vin_position` is an IMMEDIATE unique index on both dialects, so + moving FL to FR before FR has vacated violates it -- an X-pattern rotation + fails even though the requested FINAL arrangement is perfectly legal. The + service clears every affected position first, flushes, then assigns. + """ + + async def test_a_front_to_back_swap( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + fl = await _mount(client, auth_headers, vehicle, "FL", mounted_odometer_km="1000") + rl = await _mount(client, auth_headers, vehicle, "RL", mounted_odometer_km="1000") + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/rotate", + headers=auth_headers, + json={ + "odometer_km": "20000", + "moves": [ + {"tire_id": fl["id"], "position": "RL"}, + {"tire_id": rl["id"], "position": "FL"}, + ], + }, + ) + assert response.status_code == 200, response.text + + tires = {t["id"]: t for t in response.json()["tires"]} + assert tires[fl["id"]]["position"] == "RL" + assert tires[rl["id"]]["position"] == "FL" + + async def test_an_x_pattern_rotation_does_not_trip_the_unique_index( + self, client: AsyncClient, auth_headers, vehicle + ): + """The case that fails without the two-phase write. + + Every tire moves and every target is currently occupied, so a + naive one-at-a-time assignment collides on the first move. + """ + corners = {} + for position in ("FL", "FR", "RL", "RR"): + corners[position] = await _mount( + client, auth_headers, vehicle, position, mounted_odometer_km="1000" + ) + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/rotate", + headers=auth_headers, + json={ + "odometer_km": "20000", + "moves": [ + {"tire_id": corners["FL"]["id"], "position": "RR"}, + {"tire_id": corners["FR"]["id"], "position": "RL"}, + {"tire_id": corners["RL"]["id"], "position": "FR"}, + {"tire_id": corners["RR"]["id"], "position": "FL"}, + ], + }, + ) + assert response.status_code == 200, response.text + placed = {t["id"]: t["position"] for t in response.json()["tires"]} + assert placed[corners["FL"]["id"]] == "RR" + assert placed[corners["RR"]["id"]] == "FL" + + async def test_rotation_closes_and_opens_periods( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """A rotation is a dismount and a remount, so the distance at each + corner stays attributable.""" + fl = await _mount(client, auth_headers, vehicle, "FL", mounted_odometer_km="1000") + rl = await _mount(client, auth_headers, vehicle, "RL", mounted_odometer_km="1000") + + await client.post( + f"/api/vehicles/{vehicle}/tires/rotate", + headers=auth_headers, + json={ + "odometer_km": "20000", + "moves": [ + {"tire_id": fl["id"], "position": "RL"}, + {"tire_id": rl["id"], "position": "FL"}, + ], + }, + ) + + periods = ( + ( + await db_session.execute( + select(TireMountPeriod) + .where(TireMountPeriod.tire_id == fl["id"]) + .order_by(TireMountPeriod.id) + ) + ) + .scalars() + .all() + ) + assert [p.position for p in periods] == ["FL", "RL"] + assert periods[0].dismounted_odometer_km == Decimal("20000") + assert periods[1].mounted_odometer_km == Decimal("20000") + + async def test_a_rotation_onto_an_unmoved_tire_is_refused( + self, client: AsyncClient, auth_headers, vehicle + ): + """Moving FL to FR while FR stays put is a conflict, not a swap. + + Refused as a whole: a partial rotation would leave the vehicle in a + state the user did not ask for and cannot easily read back. + """ + fl = await _mount(client, auth_headers, vehicle, "FL") + await _mount(client, auth_headers, vehicle, "FR") + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/rotate", + headers=auth_headers, + json={"moves": [{"tire_id": fl["id"], "position": "FR"}]}, + ) + assert response.status_code == 409, response.text + + async def test_nothing_moves_when_one_move_is_invalid( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """Atomicity. Guards the guard on the two-phase write: a rotation that + applied its valid moves and rejected the rest would pass the conflict + test above while corrupting the vehicle.""" + fl = await _mount(client, auth_headers, vehicle, "FL") + rl = await _mount(client, auth_headers, vehicle, "RL") + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/rotate", + headers=auth_headers, + json={ + "moves": [ + {"tire_id": fl["id"], "position": "RL"}, + {"tire_id": 999999, "position": "FL"}, + ] + }, + ) + assert response.status_code == 404, response.text + + await db_session.commit() + listed = await client.get(f"/api/vehicles/{vehicle}/tires", headers=auth_headers) + placed = {t["id"]: t["position"] for t in listed.json()["tires"]} + assert placed[fl["id"]] == "FL", "a refused rotation must move nothing" + assert placed[rl["id"]] == "RL" diff --git a/backend/tests/integration/routes/test_tire_odometer_sync.py b/backend/tests/integration/routes/test_tire_odometer_sync.py new file mode 100644 index 00000000..21531700 --- /dev/null +++ b/backend/tests/integration/routes/test_tire_odometer_sync.py @@ -0,0 +1,349 @@ +"""The odometer a user types into a tire operation has to count as an odometer. + +Every tire write that takes an odometer was recording it on the mount period +and nowhere else. `distance_on_tire` bounds an OPEN period with the vehicle's +latest `OdometerRecord`, so a user who rotated their tires and dutifully +entered the odometer still saw "incomplete": the number they had just typed was +not an odometer reading as far as the rest of the app was concerned. + +Fuel records and service visits have synced one since v2.26.2 via +`sync_odometer_from_record`. These tests pin the tire paths onto the same +helper, with the same two refusals: a manual reading is never overwritten, and +the row is cleaned up when its source is deleted. + +The one deliberate asymmetry is at the bottom of this file: a ROTATION's +reading survives the deletion of a tire that took part in it. +""" + +from __future__ import annotations + +import uuid +from datetime import date + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy import delete, select + +from app.models.odometer import OdometerRecord +from app.models.vehicle import Vehicle + +TODAY = date(2026, 3, 14) + + +@pytest_asyncio.fixture +async def vehicle(db_session, test_user): + """A vehicle for this file alone, cleaned up afterwards. + + Its own VIN because these assertions count the vehicle's odometer rows, and + its own make/model because leaving look-alike vehicles behind has broken + test_vehicle.py's dashboard assertions twice. + """ + vin = f"TIREODO{uuid.uuid4().hex[:10].upper()}" + db_session.add( + Vehicle( + vin=vin, + user_id=test_user["id"], + nickname="Odometer Sync", + vehicle_type="Car", + year=2019, + make="Subaru", + model="Forester", + ) + ) + await db_session.commit() + yield vin + await db_session.execute(delete(Vehicle).where(Vehicle.vin == vin)) + await db_session.commit() + + +async def _mount(client: AsyncClient, headers, vin: str, position: str, **extra) -> dict: + body = {"vin": vin, "position": position, "tread_depth_mm": "8.0", **extra} + response = await client.post( + f"/api/vehicles/{vin}/tires/create-and-mount", headers=headers, json=body + ) + assert response.status_code == 201, response.text + return response.json() + + +async def _odometer_rows(db_session, vin: str) -> list[OdometerRecord]: + return list( + ( + await db_session.execute( + select(OdometerRecord) + .where(OdometerRecord.vin == vin) + .order_by(OdometerRecord.date, OdometerRecord.id) + ) + ) + .scalars() + .all() + ) + + +@pytest.mark.asyncio +class TestTheOdometerIsRecorded: + """One reading per operation, at the operation's own date.""" + + async def test_mounting_records_the_odometer( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + await _mount( + client, + auth_headers, + vehicle, + "FL", + mounted_on=TODAY.isoformat(), + mounted_odometer_km="20000", + ) + + rows = await _odometer_rows(db_session, vehicle) + assert [(r.date, r.odometer_km) for r in rows] == [(TODAY, 20000)] + assert rows[0].source == "tire" + + async def test_mounting_an_existing_tire_records_the_odometer( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """`create-and-mount` and `mount` are separate writers and both count.""" + created = await client.post( + f"/api/vehicles/{vehicle}/tires", + headers=auth_headers, + json={"vin": vehicle, "tread_depth_mm": "8.0"}, + ) + assert created.status_code == 201, created.text + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/{created.json()['id']}/mount", + headers=auth_headers, + json={ + "position": "FR", + "mounted_on": TODAY.isoformat(), + "mounted_odometer_km": "21000", + }, + ) + assert response.status_code == 200, response.text + + rows = await _odometer_rows(db_session, vehicle) + assert [(r.date, r.odometer_km) for r in rows] == [(TODAY, 21000)] + + async def test_dismounting_records_the_odometer( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + tire = await _mount(client, auth_headers, vehicle, "FL") + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/dismount", + headers=auth_headers, + json={"dismounted_on": TODAY.isoformat(), "dismounted_odometer_km": "25000"}, + ) + assert response.status_code == 200, response.text + + rows = await _odometer_rows(db_session, vehicle) + assert (TODAY, 25000) in [(r.date, r.odometer_km) for r in rows] + + async def test_retiring_records_the_odometer( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """Retire takes the same request body as dismount, so it is the same writer.""" + tire = await _mount(client, auth_headers, vehicle, "RL") + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/retire", + headers=auth_headers, + json={"dismounted_on": TODAY.isoformat(), "dismounted_odometer_km": "26000"}, + ) + assert response.status_code == 200, response.text + + rows = await _odometer_rows(db_session, vehicle) + assert (TODAY, 26000) in [(r.date, r.odometer_km) for r in rows] + + async def test_a_reading_records_the_odometer( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """A tread reading's odometer is vehicle context by its own schema's words. + + Without this the previous three tests make things WORSE for someone who + only records readings: mounting publishes an odometer, which becomes the + open period's upper bound, and the card reports a confident "0 km" + instead of admitting it does not know. + """ + tire = await _mount(client, auth_headers, vehicle, "RR", mounted_odometer_km="30000") + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/readings", + headers=auth_headers, + json={ + "recorded_at": TODAY.isoformat(), + "odometer_km": "34000", + "tread_depth_mm": "7.0", + }, + ) + assert response.status_code == 201, response.text + + rows = await _odometer_rows(db_session, vehicle) + assert (TODAY, 34000) in [(r.date, r.odometer_km) for r in rows] + + async def test_rotating_records_one_reading_and_completes_the_distance( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """The case that started this: the payoff is `complete`, not `incomplete`. + + A rotation writes ONE reading however many tires moved, because the + odometer is a fact about the vehicle, not about each corner. + """ + left = await _mount( + client, + auth_headers, + vehicle, + "FL", + mounted_on=date(2026, 1, 1).isoformat(), + mounted_odometer_km="1000", + ) + right = await _mount( + client, + auth_headers, + vehicle, + "FR", + mounted_on=date(2026, 1, 1).isoformat(), + mounted_odometer_km="1000", + ) + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/rotate", + headers=auth_headers, + json={ + "moves": [ + {"tire_id": left["id"], "position": "RR"}, + {"tire_id": right["id"], "position": "RL"}, + ], + "rotated_on": TODAY.isoformat(), + "odometer_km": "20000", + }, + ) + assert response.status_code == 200, response.text + + # Two mounts on 2026-01-01 collapse to ONE reading for that date, and + # the rotation adds exactly one more. Not three. + rows = await _odometer_rows(db_session, vehicle) + assert [(r.date, r.odometer_km) for r in rows] == [ + (date(2026, 1, 1), 1000), + (TODAY, 20000), + ] + + for tire in response.json()["tires"]: + assert tire["distance_status"] == "complete", tire + assert tire["distance_km"] == "19000.00", tire + + +@pytest.mark.asyncio +class TestItRefusesToOverwriteAManualReading: + async def test_a_manual_reading_on_the_same_date_wins( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """The user's own entry is authoritative. Same rule as fuel and service.""" + db_session.add( + OdometerRecord(vin=vehicle, date=TODAY, odometer_km=44000, notes="Read it myself") + ) + await db_session.commit() + + await _mount( + client, + auth_headers, + vehicle, + "FL", + mounted_on=TODAY.isoformat(), + mounted_odometer_km="20000", + ) + + rows = await _odometer_rows(db_session, vehicle) + assert [(r.date, r.odometer_km, r.notes) for r in rows] == [ + (TODAY, 44000, "Read it myself") + ] + + +@pytest.mark.asyncio +class TestDeletingTheSourceRemovesTheReading: + """The other half of the boundary. + + Nothing cascades these rows: `odometer_records` carries a FK for + fuel-sourced rows only. A tire entered with a typo'd odometer and then + deleted would otherwise leave the typo behind, poisoning every mileage + reminder the vehicle has. + """ + + async def test_deleting_a_tire_removes_the_reading_it_synced( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + tire = await _mount( + client, + auth_headers, + vehicle, + "FL", + mounted_on=TODAY.isoformat(), + mounted_odometer_km="900000", + ) + + response = await client.delete( + f"/api/vehicles/{vehicle}/tires/{tire['id']}", headers=auth_headers + ) + assert response.status_code == 204, response.text + + assert await _odometer_rows(db_session, vehicle) == [] + + async def test_deleting_a_tire_leaves_a_manual_reading_alone( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + db_session.add( + OdometerRecord( + vin=vehicle, date=date(2026, 2, 1), odometer_km=15000, notes="Read it myself" + ) + ) + await db_session.commit() + + tire = await _mount( + client, + auth_headers, + vehicle, + "FL", + mounted_on=TODAY.isoformat(), + mounted_odometer_km="20000", + ) + await client.delete(f"/api/vehicles/{vehicle}/tires/{tire['id']}", headers=auth_headers) + + rows = await _odometer_rows(db_session, vehicle) + assert [(r.date, r.odometer_km) for r in rows] == [(date(2026, 2, 1), 15000)] + + async def test_deleting_a_rotated_tire_keeps_the_rotations_reading( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """The deliberate asymmetry. + + A mount's odometer exists because of ONE tire, so it goes when that + tire goes. A rotation's odometer is a reading of the vehicle taken + while several tires were on it; deleting one of them does not make the + reading untrue, and cascading it would break the distance figure for + every other tire in the same rotation. + """ + left = await _mount(client, auth_headers, vehicle, "FL") + right = await _mount(client, auth_headers, vehicle, "FR") + + response = await client.post( + f"/api/vehicles/{vehicle}/tires/rotate", + headers=auth_headers, + json={ + "moves": [ + {"tire_id": left["id"], "position": "RR"}, + {"tire_id": right["id"], "position": "RL"}, + ], + "rotated_on": TODAY.isoformat(), + "odometer_km": "20000", + }, + ) + assert response.status_code == 200, response.text + + deleted = await client.delete( + f"/api/vehicles/{vehicle}/tires/{left['id']}", headers=auth_headers + ) + assert deleted.status_code == 204, deleted.text + + rows = await _odometer_rows(db_session, vehicle) + assert [(r.date, r.odometer_km) for r in rows] == [(TODAY, 20000)] diff --git a/backend/tests/integration/routes/test_tire_sets.py b/backend/tests/integration/routes/test_tire_sets.py new file mode 100644 index 00000000..8b6d6a4a --- /dev/null +++ b/backend/tests/integration/routes/test_tire_sets.py @@ -0,0 +1,540 @@ +"""Tire sets: naming a seasonal set, and fitting it in one action. + +D6 of the mount-period design: sets are UX grouping only. No calculation +depends on membership -- distance, wear and position all read +`tire_mount_periods`. A set exists so a user can say "Winter studded" and swap +four tires at once instead of doing eight operations by hand. + +The table and the `tires.set_id` column shipped with migration 097 and then sat +there: there was no schema to create a set, no way to put a tire in one, and no +endpoint to fit one. This is that surface. + +The interesting behaviour is all in the fit: where each tire goes is REMEMBERED +rather than asked for, and everything the incoming set displaces has to come off +in the same transaction. +""" + +from __future__ import annotations + +import uuid +from datetime import date + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy import delete, select + +from app.models.odometer import OdometerRecord +from app.models.tire import Tire +from app.models.vehicle import Vehicle + +TODAY = date(2026, 4, 1) + + +def _vin(prefix: str) -> str: + return f"{prefix}{uuid.uuid4().hex[:10].upper()}".replace("I", "X")[:17].ljust(17, "0") + + +@pytest_asyncio.fixture +async def vehicle(db_session, test_user): + """A vehicle of this file's own, with a distinctive make/model.""" + vin = _vin("TIRESET") + db_session.add( + Vehicle( + vin=vin, + user_id=test_user["id"], + nickname="Set Rig", + vehicle_type="Car", + year=2021, + make="Volvo", + model="XC60", + ) + ) + await db_session.commit() + yield vin + await db_session.execute(delete(Vehicle).where(Vehicle.vin == vin)) + await db_session.commit() + + +@pytest_asyncio.fixture +async def other_vehicle(db_session, test_user): + """A second vehicle, for the cross-VIN refusals.""" + vin = _vin("TIRESETB") + db_session.add( + Vehicle( + vin=vin, + user_id=test_user["id"], + nickname="Set Rig B", + vehicle_type="Car", + year=2021, + make="Volvo", + model="V90", + ) + ) + await db_session.commit() + yield vin + await db_session.execute(delete(Vehicle).where(Vehicle.vin == vin)) + await db_session.commit() + + +async def _set(client: AsyncClient, headers, vin: str, name: str) -> dict: + response = await client.post( + f"/api/vehicles/{vin}/tire-sets", headers=headers, json={"name": name} + ) + assert response.status_code == 201, response.text + return response.json() + + +async def _mount(client: AsyncClient, headers, vin: str, position: str, **extra) -> dict: + body = {"vin": vin, "position": position, "tread_depth_mm": "8.0", **extra} + response = await client.post( + f"/api/vehicles/{vin}/tires/create-and-mount", headers=headers, json=body + ) + assert response.status_code == 201, response.text + return response.json() + + +async def _stored(client: AsyncClient, headers, vin: str, **extra) -> dict: + response = await client.post( + f"/api/vehicles/{vin}/tires", headers=headers, json={"vin": vin, **extra} + ) + assert response.status_code == 201, response.text + return response.json() + + +async def _assign(client: AsyncClient, headers, vin: str, tire_id: int, set_id: int | None): + response = await client.put( + f"/api/vehicles/{vin}/tires/{tire_id}", headers=headers, json={"set_id": set_id} + ) + return response + + +@pytest.mark.asyncio +class TestSetCrud: + async def test_create_list_rename_and_delete(self, client: AsyncClient, auth_headers, vehicle): + created = await _set(client, auth_headers, vehicle, "Winter studded") + assert created["name"] == "Winter studded" + assert created["tire_ids"] == [] + + listed = await client.get(f"/api/vehicles/{vehicle}/tire-sets", headers=auth_headers) + assert listed.status_code == 200, listed.text + assert [s["name"] for s in listed.json()["sets"]] == ["Winter studded"] + + renamed = await client.put( + f"/api/vehicles/{vehicle}/tire-sets/{created['id']}", + headers=auth_headers, + json={"name": "Winter studded (Nokian)"}, + ) + assert renamed.status_code == 200, renamed.text + assert renamed.json()["name"] == "Winter studded (Nokian)" + + removed = await client.delete( + f"/api/vehicles/{vehicle}/tire-sets/{created['id']}", headers=auth_headers + ) + assert removed.status_code == 204, removed.text + emptied = await client.get(f"/api/vehicles/{vehicle}/tire-sets", headers=auth_headers) + assert emptied.json()["sets"] == [] + + async def test_deleting_a_set_keeps_its_tires( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """ON DELETE SET NULL, not CASCADE. A set is a label, not a container. + + Cascading here would mean renaming your winter set wrong once and then + deleting it takes four tires and a season of readings with it. + """ + winter = await _set(client, auth_headers, vehicle, "Winter") + tire = await _stored(client, auth_headers, vehicle, brand="Nokian") + assert ( + await _assign(client, auth_headers, vehicle, tire["id"], winter["id"]) + ).status_code == 200 + + await client.delete( + f"/api/vehicles/{vehicle}/tire-sets/{winter['id']}", headers=auth_headers + ) + + stored = ( + await db_session.execute(select(Tire).where(Tire.id == tire["id"])) + ).scalar_one_or_none() + assert stored is not None + assert stored.set_id is None + + async def test_a_set_lists_its_tires(self, client: AsyncClient, auth_headers, vehicle): + winter = await _set(client, auth_headers, vehicle, "Winter") + first = await _stored(client, auth_headers, vehicle, brand="Nokian") + second = await _stored(client, auth_headers, vehicle, brand="Nokian") + for tire in (first, second): + await _assign(client, auth_headers, vehicle, tire["id"], winter["id"]) + + listed = await client.get(f"/api/vehicles/{vehicle}/tire-sets", headers=auth_headers) + row = listed.json()["sets"][0] + assert sorted(row["tire_ids"]) == sorted([first["id"], second["id"]]) + assert row["mounted_count"] == 0 + + +@pytest.mark.asyncio +class TestSetsAreScopedToTheirVehicle: + """`tires.set_id` carries NO database constraint against the tire's vin. + + The design says so deliberately: a set is UX grouping no calculation + depends on, so it is not worth a composite FK. That makes the service the + only thing standing between a user and a tire filed under another + vehicle's set, which is why it gets a test of its own. + """ + + async def test_a_tire_cannot_join_another_vehicles_set( + self, client: AsyncClient, auth_headers, vehicle, other_vehicle + ): + theirs = await _set(client, auth_headers, other_vehicle, "Not mine") + tire = await _stored(client, auth_headers, vehicle, brand="Nokian") + + response = await _assign(client, auth_headers, vehicle, tire["id"], theirs["id"]) + assert response.status_code == 404, response.text + + async def test_another_vehicles_set_cannot_be_renamed_through_this_vin( + self, client: AsyncClient, auth_headers, vehicle, other_vehicle + ): + theirs = await _set(client, auth_headers, other_vehicle, "Not mine") + response = await client.put( + f"/api/vehicles/{vehicle}/tire-sets/{theirs['id']}", + headers=auth_headers, + json={"name": "Mine now"}, + ) + assert response.status_code == 404, response.text + + +@pytest.mark.asyncio +class TestFittingASet: + """The one action that replaces eight.""" + + async def test_each_tire_goes_back_where_it_last_was( + self, client: AsyncClient, auth_headers, vehicle + ): + """Position is REMEMBERED, from each tire's own mount history. + + A seasonal set has been on this car before, and the periods already + record which corner each tire sat on. Asking again would be asking the + user to retype something the app knows. + """ + summer = await _set(client, auth_headers, vehicle, "Summer") + winter = await _set(client, auth_headers, vehicle, "Winter") + + # The winter set, mounted once and taken off: that is the history the + # fit reads back. + winter_tires = {} + for position in ("FL", "FR"): + tire = await _mount( + client, + auth_headers, + vehicle, + position, + brand=f"Nokian {position}", + mounted_on="2026-01-05", + mounted_odometer_km="10000", + ) + await _assign(client, auth_headers, vehicle, tire["id"], winter["id"]) + winter_tires[position] = tire["id"] + dismounted = await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/dismount", + headers=auth_headers, + json={"dismounted_on": "2026-03-01", "dismounted_odometer_km": "16000"}, + ) + assert dismounted.status_code == 200, dismounted.text + + # The summer set is on the car now. + summer_tires = {} + for position in ("FL", "FR"): + tire = await _mount( + client, + auth_headers, + vehicle, + position, + brand=f"Michelin {position}", + mounted_on="2026-03-01", + mounted_odometer_km="16000", + ) + await _assign(client, auth_headers, vehicle, tire["id"], summer["id"]) + summer_tires[position] = tire["id"] + + fitted = await client.post( + f"/api/vehicles/{vehicle}/tire-sets/{winter['id']}/mount", + headers=auth_headers, + json={"mounted_on": TODAY.isoformat(), "odometer_km": "24000"}, + ) + assert fitted.status_code == 200, fitted.text + + by_id = {t["id"]: t for t in fitted.json()["tires"]} + assert by_id[winter_tires["FL"]]["position"] == "FL" + assert by_id[winter_tires["FR"]]["position"] == "FR" + # And what they displaced came off in the same operation. + assert by_id[summer_tires["FL"]]["position"] is None + assert by_id[summer_tires["FR"]]["position"] is None + + async def test_the_displaced_set_keeps_a_bounded_period( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """The swap's odometer closes the outgoing periods as well as opening + the incoming ones. A dismount with no closing bound is a period whose + distance can never be computed, which is the dead end the whole release + exists to get out of.""" + winter = await _set(client, auth_headers, vehicle, "Winter") + old = await _mount( + client, + auth_headers, + vehicle, + "RL", + brand="Outgoing", + mounted_on="2026-01-01", + mounted_odometer_km="5000", + ) + incoming = await _mount( + client, + auth_headers, + vehicle, + "RR", + brand="Incoming", + mounted_on="2025-01-01", + mounted_odometer_km="1000", + ) + await client.post( + f"/api/vehicles/{vehicle}/tires/{incoming['id']}/dismount", + headers=auth_headers, + json={"dismounted_on": "2025-06-01", "dismounted_odometer_km": "4000"}, + ) + await _assign(client, auth_headers, vehicle, incoming["id"], winter["id"]) + + # The incoming tire remembers RR, which is free, so `old` at RL is not + # displaced -- the fit only touches the corners it needs. + fitted = await client.post( + f"/api/vehicles/{vehicle}/tire-sets/{winter['id']}/mount", + headers=auth_headers, + json={"mounted_on": TODAY.isoformat(), "odometer_km": "24000"}, + ) + assert fitted.status_code == 200, fitted.text + by_id = {t["id"]: t for t in fitted.json()["tires"]} + assert by_id[incoming["id"]]["position"] == "RR" + assert by_id[old["id"]]["position"] == "RL" + + # One reading for the whole swap, like a rotation: the odometer is a + # fact about the vehicle, not about each corner. + rows = ( + ( + await db_session.execute( + select(OdometerRecord).where( + OdometerRecord.vin == vehicle, OdometerRecord.date == TODAY + ) + ) + ) + .scalars() + .all() + ) + assert len(rows) == 1 + assert rows[0].odometer_km == 24000 + + async def test_a_tire_that_has_never_been_mounted_refuses_the_fit( + self, client: AsyncClient, auth_headers, vehicle + ): + """Named, and nothing is written. + + A brand-new set entered into storage has no history to read a corner + from. Guessing one would put a tire somewhere the user did not choose, + and doing three of four would leave an arrangement nobody asked for. + """ + winter = await _set(client, auth_headers, vehicle, "Winter") + known = await _mount( + client, auth_headers, vehicle, "FL", brand="Known", mounted_odometer_km="1000" + ) + await client.post( + f"/api/vehicles/{vehicle}/tires/{known['id']}/dismount", + headers=auth_headers, + json={"dismounted_odometer_km": "2000"}, + ) + fresh = await _stored(client, auth_headers, vehicle, brand="NeverFitted") + for tire in (known, fresh): + await _assign(client, auth_headers, vehicle, tire["id"], winter["id"]) + + response = await client.post( + f"/api/vehicles/{vehicle}/tire-sets/{winter['id']}/mount", + headers=auth_headers, + json={"odometer_km": "24000"}, + ) + assert response.status_code == 409, response.text + assert "NeverFitted" in response.text + + # All or nothing: the tire that DID have a corner stayed in storage. + listed = await client.get(f"/api/vehicles/{vehicle}/tires", headers=auth_headers) + by_id = {t["id"]: t for t in listed.json()["tires"]} + assert by_id[known["id"]]["position"] is None + + async def test_two_tires_wanting_one_corner_refuses_the_fit( + self, client: AsyncClient, auth_headers, vehicle + ): + """Two tires whose last corner was the same cannot both go back.""" + winter = await _set(client, auth_headers, vehicle, "Winter") + for brand in ("First", "Second"): + tire = await _mount( + client, auth_headers, vehicle, "SPARE", brand=brand, mounted_odometer_km="1000" + ) + await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/dismount", + headers=auth_headers, + json={"dismounted_odometer_km": "2000"}, + ) + await _assign(client, auth_headers, vehicle, tire["id"], winter["id"]) + + response = await client.post( + f"/api/vehicles/{vehicle}/tire-sets/{winter['id']}/mount", + headers=auth_headers, + json={"odometer_km": "24000"}, + ) + assert response.status_code == 409, response.text + assert "SPARE" in response.text + + async def test_an_empty_set_refuses_the_fit(self, client: AsyncClient, auth_headers, vehicle): + winter = await _set(client, auth_headers, vehicle, "Winter") + response = await client.post( + f"/api/vehicles/{vehicle}/tire-sets/{winter['id']}/mount", + headers=auth_headers, + json={"odometer_km": "24000"}, + ) + assert response.status_code == 409, response.text + + async def test_fitting_a_set_that_is_already_on_changes_nothing( + self, client: AsyncClient, auth_headers, vehicle, db_session + ): + """A member already sitting on its own corner is left alone. + + Taking it off and putting it straight back would close a period and + open an identical one, splitting the tire's history at a moment when + nothing happened to it -- and the two fragments would each be bounded + by the same odometer, so the split leg would read as 0 km driven. + """ + winter = await _set(client, auth_headers, vehicle, "Winter") + tire = await _mount( + client, auth_headers, vehicle, "FL", brand="Nokian", mounted_odometer_km="1000" + ) + await _assign(client, auth_headers, vehicle, tire["id"], winter["id"]) + + first = await client.post( + f"/api/vehicles/{vehicle}/tire-sets/{winter['id']}/mount", + headers=auth_headers, + json={"mounted_on": TODAY.isoformat(), "odometer_km": "24000"}, + ) + assert first.status_code == 200, first.text + periods = first.json()["tires"][0]["mount_periods"] + assert len(periods) == 1, periods + assert periods[0]["dismounted_on"] is None + + async def test_the_most_recently_recorded_corner_wins( + self, client: AsyncClient, auth_headers, vehicle + ): + """A tire that has been on two corners goes back to the later one. + + And "later" means most recently RECORDED, not the largest + `mounted_on`: the period entered second below is BACKDATED to 2024, + before the first one, so a rule that read dates would send this tire to + FL. Someone filling in last winter's history must not have that + backfill outrank the corner the tire actually came off. + """ + winter = await _set(client, auth_headers, vehicle, "Winter") + tire = await _mount( + client, + auth_headers, + vehicle, + "FL", + brand="TwoCorners", + mounted_on="2026-01-01", + mounted_odometer_km="1000", + ) + await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/dismount", + headers=auth_headers, + json={"dismounted_on": "2026-02-01", "dismounted_odometer_km": "2000"}, + ) + remount = await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/mount", + headers=auth_headers, + json={"position": "RR", "mounted_on": "2024-01-01", "mounted_odometer_km": "500"}, + ) + assert remount.status_code == 200, remount.text + await client.post( + f"/api/vehicles/{vehicle}/tires/{tire['id']}/dismount", + headers=auth_headers, + json={"dismounted_on": "2024-06-01", "dismounted_odometer_km": "800"}, + ) + await _assign(client, auth_headers, vehicle, tire["id"], winter["id"]) + + fitted = await client.post( + f"/api/vehicles/{vehicle}/tire-sets/{winter['id']}/mount", + headers=auth_headers, + json={"mounted_on": TODAY.isoformat(), "odometer_km": "24000"}, + ) + assert fitted.status_code == 200, fitted.text + placed = {t["id"]: t["position"] for t in fitted.json()["tires"]} + assert placed[tire["id"]] == "RR" + + async def test_a_retired_member_leaves_the_sets_membership( + self, client: AsyncClient, auth_headers, vehicle + ): + """The set listing counts live tires only. + + A retired member left in `tire_ids` would offer to fit a tire the user + has thrown away, and "3 of 4 fitted" would never reach 4. + """ + winter = await _set(client, auth_headers, vehicle, "Winter") + keeper = await _mount( + client, auth_headers, vehicle, "FL", brand="Keeper", mounted_odometer_km="1000" + ) + goner = await _mount( + client, auth_headers, vehicle, "FR", brand="Goner", mounted_odometer_km="1000" + ) + for tire in (keeper, goner): + await _assign(client, auth_headers, vehicle, tire["id"], winter["id"]) + + before = await client.get(f"/api/vehicles/{vehicle}/tire-sets", headers=auth_headers) + assert before.json()["sets"][0]["mounted_count"] == 2 + + await client.post( + f"/api/vehicles/{vehicle}/tires/{goner['id']}/retire", + headers=auth_headers, + json={"dismounted_odometer_km": "2000"}, + ) + + after = await client.get(f"/api/vehicles/{vehicle}/tire-sets", headers=auth_headers) + row = after.json()["sets"][0] + assert row["tire_ids"] == [keeper["id"]] + assert row["mounted_count"] == 1 + + async def test_a_retired_tire_is_not_fitted(self, client: AsyncClient, auth_headers, vehicle): + """A retired tire is history, not inventory, so it is not in the set + the fit operates on -- and it must not make the fit fail either.""" + winter = await _set(client, auth_headers, vehicle, "Winter") + keeper = await _mount( + client, auth_headers, vehicle, "FL", brand="Keeper", mounted_odometer_km="1000" + ) + goner = await _mount( + client, auth_headers, vehicle, "FR", brand="Goner", mounted_odometer_km="1000" + ) + for tire in (keeper, goner): + await _assign(client, auth_headers, vehicle, tire["id"], winter["id"]) + await client.post( + f"/api/vehicles/{vehicle}/tires/{keeper['id']}/dismount", + headers=auth_headers, + json={"dismounted_odometer_km": "2000"}, + ) + retired = await client.post( + f"/api/vehicles/{vehicle}/tires/{goner['id']}/retire", + headers=auth_headers, + json={"dismounted_odometer_km": "2000"}, + ) + assert retired.status_code == 200, retired.text + + fitted = await client.post( + f"/api/vehicles/{vehicle}/tire-sets/{winter['id']}/mount", + headers=auth_headers, + json={"odometer_km": "24000"}, + ) + assert fitted.status_code == 200, fitted.text + by_id = {t["id"]: t for t in fitted.json()["tires"]} + assert by_id[keeper["id"]]["position"] == "FL" + assert goner["id"] not in by_id diff --git a/backend/tests/integration/routes/test_tires.py b/backend/tests/integration/routes/test_tires.py index b528e8b9..c8b97ebe 100644 --- a/backend/tests/integration/routes/test_tires.py +++ b/backend/tests/integration/routes/test_tires.py @@ -3,9 +3,54 @@ from decimal import Decimal import pytest +import pytest_asyncio from httpx import AsyncClient +@pytest_asyncio.fixture +async def test_vehicle(db_session, test_user): + """A vehicle owned by THIS test alone, shadowing the shared conftest one. + + The suite shares one database with no per-test rollback, and the shared + `test_vehicle` fixture hands every test the same VIN. That was survivable + while `POST /tires` upserted by position: a second test claiming FL simply + overwrote the first one's tire. + + v3.3.0 makes a corner claimable once -- a second POST to an occupied + position is a 409 -- so tests in this file would collide with each other + depending on execution order. Each gets its own vehicle instead, which is + the fix the upsert semantics were previously hiding the need for. + """ + import uuid + + from app.models.vehicle import Vehicle + + vin = f"TIRETEST{uuid.uuid4().hex[:9].upper()}" + db_session.add( + Vehicle( + vin=vin, + user_id=test_user["id"], + nickname="Tire test", + vehicle_type="Car", + year=2020, + make="Honda", + model="Accord", + ) + ) + await db_session.commit() + + yield {"vin": vin, "user_id": test_user["id"]} + + # Clean up. The suite shares one database, and leaving a vehicle behind per + # test is not harmless: `test_list_vehicles` asserts a specific VIN appears + # in a PAGINATED listing, so nine extra vehicles pushed it off the page and + # failed a test in another file entirely. + from sqlalchemy import delete + + await db_session.execute(delete(Vehicle).where(Vehicle.vin == vin)) + await db_session.commit() + + @pytest.mark.integration @pytest.mark.asyncio class TestTireRoutes: @@ -24,7 +69,7 @@ async def test_list_empty(self, client: AsyncClient, auth_headers, test_vehicle) async def test_upsert_list_and_delete(self, client: AsyncClient, auth_headers, test_vehicle): vin = test_vehicle["vin"] create = await client.post( - f"/api/vehicles/{vin}/tires", + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={ "vin": vin, @@ -70,10 +115,28 @@ async def test_invalid_position_rejected(self, client: AsyncClient, auth_headers ) assert response.status_code == 422 - async def test_readings_project_wear(self, client: AsyncClient, auth_headers, test_vehicle): + async def test_readings_project_wear( + self, client: AsyncClient, auth_headers, test_vehicle, db_session + ): + """A projection needs the tire's OWN distance, which needs bounds. + + The open mount period's upper bound is the vehicle's latest + `OdometerRecord` -- there is no odometer column on `Vehicle`, and a + tire reading's odometer does not create one. A vehicle with no odometer + record therefore has no bounded distance for any tire still mounted on + it, which is its own empty state rather than a zero. + """ + import datetime as _dt + + from app.models.odometer import OdometerRecord + vin = test_vehicle["vin"] + db_session.add( + OdometerRecord(vin=vin, date=_dt.date(2026, 6, 1), odometer_km=12000, source="manual") + ) + await db_session.commit() created = await client.post( - f"/api/vehicles/{vin}/tires", + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={ "vin": vin, @@ -81,6 +144,13 @@ async def test_readings_project_wear(self, client: AsyncClient, auth_headers, te "brand": "Continental", "tread_depth_mm": "6.0", "min_tread_mm": "2.0", + # v3.3.0: the projection is period-aware. Without an odometer + # on the mount there is no bounded distance for this tire, and + # the raw odometer delta between two readings -- which is what + # the old code used -- is exactly the figure this release + # exists to stop publishing. The status in that case is + # `unverified_mount_history`, asserted separately below. + "mounted_odometer_km": "10000", }, ) assert created.status_code == 201 @@ -119,7 +189,7 @@ async def test_low_tread_creates_reminder( ): vin = test_vehicle["vin"] created = await client.post( - f"/api/vehicles/{vin}/tires", + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={ "vin": vin, @@ -144,7 +214,7 @@ async def test_low_tread_creates_reminder( async def test_update_tire_metadata(self, client: AsyncClient, auth_headers, test_vehicle): vin = test_vehicle["vin"] created = await client.post( - f"/api/vehicles/{vin}/tires", + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={ "vin": vin, @@ -176,15 +246,18 @@ async def test_low_tread_reminder_cleared_with_done_status( from app.models.reminder import Reminder vin = test_vehicle["vin"] - await client.post( - f"/api/vehicles/{vin}/tires", + # v3.3.0: a second POST to an occupied corner is a 409, not an upsert. + # A tire is a thing you own, so changing its tread is a PUT on that tire. + created = await client.post( + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={"vin": vin, "position": "RL", "tread_depth_mm": "2.0", "min_tread_mm": "3.0"}, ) - await client.post( - f"/api/vehicles/{vin}/tires", + assert created.status_code == 201, created.text + await client.put( + f"/api/vehicles/{vin}/tires/{created.json()['id']}", headers=auth_headers, - json={"vin": vin, "position": "RL", "tread_depth_mm": "8.0", "min_tread_mm": "3.0"}, + json={"tread_depth_mm": "8.0"}, ) result = await db_session.execute( select(Reminder).where(Reminder.vin == vin, Reminder.title == "Tire tread low (RL)") @@ -202,7 +275,7 @@ async def test_upsert_does_not_wipe_unspecified_fields( """ vin = test_vehicle["vin"] created = await client.post( - f"/api/vehicles/{vin}/tires", + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={ "vin": vin, @@ -217,13 +290,16 @@ async def test_upsert_does_not_wipe_unspecified_fields( ) assert created.status_code in (200, 201) - # A later save that only carries a new tread reading. - updated = await client.post( - f"/api/vehicles/{vin}/tires", + # A later save that only carries a new tread reading. Before v3.3.0 + # this was a second POST to the same position; it is now a partial PUT, + # because a POST to an occupied corner is a conflict. The property being + # protected is unchanged: a save that omits a field must not erase it. + updated = await client.put( + f"/api/vehicles/{vin}/tires/{created.json()['id']}", headers=auth_headers, - json={"vin": vin, "position": "FR", "tread_depth_mm": "6.0"}, + json={"tread_depth_mm": "6.0"}, ) - assert updated.status_code in (200, 201) + assert updated.status_code == 200, updated.text body = updated.json() assert body["brand"] == "Michelin" assert body["model_name"] == "Pilot Sport 4" @@ -239,7 +315,7 @@ async def test_backdated_reading_does_not_change_current_tread( """Backfilling history must not report a worn tire as healthy.""" vin = test_vehicle["vin"] created = await client.post( - f"/api/vehicles/{vin}/tires", + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={"vin": vin, "position": "RR", "tread_depth_mm": "4.0", "min_tread_mm": "3.0"}, ) @@ -346,7 +422,7 @@ async def test_pressure_only_reading_keeps_tread_and_leaves_reminder_pending( title = "Tire tread low (FL)" created = await client.post( - f"/api/vehicles/{vin}/tires", + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={ "vin": vin, @@ -413,7 +489,7 @@ async def test_clearing_a_tire_tread_leaves_the_reminder_pending( title = "Tire tread low (RR)" created = await client.post( - f"/api/vehicles/{vin}/tires", + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={"vin": vin, "position": "RR", "tread_depth_mm": "1.5", "min_tread_mm": "3.0"}, ) @@ -421,12 +497,14 @@ async def test_clearing_a_tire_tread_leaves_the_reminder_pending( assert created.json()["below_threshold"] is True assert await self._reminder_status(db_session, vin, title) == "pending" - cleared = await client.post( - f"/api/vehicles/{vin}/tires", + # v3.3.0: clearing a tread is an update to THAT tire, not a re-POST to + # the corner it happens to occupy. A second POST there is a 409. + cleared = await client.put( + f"/api/vehicles/{vin}/tires/{created.json()['id']}", headers=auth_headers, - json={"vin": vin, "position": "RR", "tread_depth_mm": None}, + json={"tread_depth_mm": None}, ) - assert cleared.status_code in (200, 201), cleared.text + assert cleared.status_code == 200, cleared.text assert cleared.json()["tread_depth_mm"] is None assert await self._reminder_status(db_session, vin, title) == "pending" @@ -440,7 +518,7 @@ async def test_reading_without_tread_or_pressure_is_rejected( """ vin = await self._vehicle(client, auth_headers, "1HGCM82633A152003") created = await client.post( - f"/api/vehicles/{vin}/tires", + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={"vin": vin, "position": "FR", "tread_depth_mm": "6.0"}, ) @@ -476,7 +554,7 @@ async def test_pressure_only_reading_round_trips_through_the_api( """The #152 flow end to end: no tread anywhere, pressure history only.""" vin = await self._vehicle(client, auth_headers, "1HGCM82633A152004") created = await client.post( - f"/api/vehicles/{vin}/tires", + f"/api/vehicles/{vin}/tires/create-and-mount", headers=auth_headers, json={"vin": vin, "position": "RL", "pressure_kpa": "240"}, ) diff --git a/backend/tests/integration/routes/test_vehicle_detail_stats.py b/backend/tests/integration/routes/test_vehicle_detail_stats.py index 6f055970..158fd04d 100644 --- a/backend/tests/integration/routes/test_vehicle_detail_stats.py +++ b/backend/tests/integration/routes/test_vehicle_detail_stats.py @@ -160,7 +160,12 @@ async def test_overdue_and_upcoming_disjoint( title="Done", reminder_type="date", due_date=today + timedelta(days=1), - status="completed", + # 'done', not 'completed': check_reminder_status has always + # been (pending, done, dismissed) in the migrated schema, and + # nothing in the app writes "completed". This fixture only + # passed because the ORM declared no CHECK, so create_all + # databases accepted a value production rejects. + status="done", ), # neither ] ) diff --git a/backend/tests/integration/routes/test_warranty_insurance_tax_csv.py b/backend/tests/integration/routes/test_warranty_insurance_tax_csv.py new file mode 100644 index 00000000..5fe39b1d --- /dev/null +++ b/backend/tests/integration/routes/test_warranty_insurance_tax_csv.py @@ -0,0 +1,340 @@ +"""Warranty, insurance and tax CSV, in both directions. + +Three record types were broken on both sides of the round trip: + +* **Export** read attributes the model does not have. `WarrantyRecord.coverage` + (it is `coverage_details`), plus `cost`, `deductible`, `max_claims` and + `terms`, which do not exist at all; and `InsurancePolicy.premium` (it is + `premium_amount`). Any vehicle with such a record returned 500. +* **Import** constructed with the same nonexistent kwargs, plus + `TaxRecord(year=, paid_date=, due_date=, jurisdiction=)` -- four more. The + `TypeError` was caught per row and reported as "Invalid record data", so the + endpoint returned **200 while blaming the user's file** for an application + bug. No tax record has ever imported successfully. +* The tax **export** was fine, which is why enumerating from the export bug + never found the tax importer. Its headers (`Date`, `Renewal Date`) and its + importer's headers (`Year`, `Paid Date`, `Due Date`, `Jurisdiction`) were + different vocabularies, so the file it produced could not be read back even + before the constructor raised. + +The round trip is the assertion that covers all of it: export a record, import +the file, get the record back. +""" + +import io +from datetime import date +from decimal import Decimal + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.insurance import InsurancePolicy +from app.models.tax import TaxRecord +from app.models.warranty import WarrantyRecord + + +def _csv_rows(body: str) -> tuple[list[str], list[list[str]]]: + import csv as _csv + + rows = list(_csv.reader(io.StringIO(body))) + return rows[0], rows[1:] + + +@pytest.mark.asyncio +class TestWarrantyCsv: + async def test_export_does_not_500_and_carries_the_real_fields( + self, client: AsyncClient, db_session: AsyncSession, test_vehicle, auth_headers + ): + """The original defect: `record.coverage` raised AttributeError.""" + vin = str(test_vehicle["vin"]) + db_session.add( + WarrantyRecord( + vin=vin, + warranty_type="Powertrain", + provider="Acme", + start_date=date(2026, 1, 1), + end_date=date(2031, 1, 1), + mileage_limit_km=Decimal("100000"), + coverage_details="5yr / 100k", + policy_number="W-123", + notes="n", + ) + ) + await db_session.commit() + + r = await client.get(f"/api/export/vehicles/{vin}/warranties/csv", headers=auth_headers) + assert r.status_code == 200, r.text + headers, rows = _csv_rows(r.text) + assert "Coverage Details" in headers + assert "Policy Number" in headers + # The four with no model field are gone. + for gone in ("Cost", "Max Claims", "Terms"): + assert gone not in headers, f"{gone} has no source on WarrantyRecord" + row = next(x for x in rows if x[headers.index("Provider")] == "Acme") + assert row[headers.index("Coverage Details")] == "5yr / 100k" + assert row[headers.index("Policy Number")] == "W-123" + + @pytest.mark.parametrize( + "unit_query,header,expected", + [("metric", "Mileage Limit (km)", "100000"), ("imperial", "Mileage Limit (mi)", "62137")], + ) + async def test_mileage_limit_is_emitted_in_the_readers_units( + self, + client: AsyncClient, + db_session: AsyncSession, + test_vehicle, + auth_headers, + unit_query, + header, + expected, + ): + """`mileage_limit_km` is unit-bearing and was never exported at all. + + Asserts the TRANSFORMED value, not just the header: a test that checked + only the dimensionless columns would pass with the conversion missing. + """ + vin = str(test_vehicle["vin"]) + db_session.add( + WarrantyRecord( + vin=vin, + warranty_type="Bumper-to-Bumper", + provider="Units", + start_date=date(2026, 2, 1), + mileage_limit_km=Decimal("100000"), + ) + ) + await db_session.commit() + + r = await client.get( + f"/api/export/vehicles/{vin}/warranties/csv?units={unit_query}", headers=auth_headers + ) + assert r.status_code == 200, r.text + headers, rows = _csv_rows(r.text) + assert header in headers, f"expected {header}, got {headers}" + row = next(x for x in rows if x[headers.index("Provider")] == "Units") + assert row[headers.index(header)].startswith(expected) + + async def test_round_trip( + self, client: AsyncClient, db_session: AsyncSession, test_vehicle, auth_headers + ): + """Export, re-import, and get the record back. + + This is the assertion no earlier revision could write, because the + importer was believed not to exist. + """ + vin = str(test_vehicle["vin"]) + db_session.add( + WarrantyRecord( + vin=vin, + warranty_type="Corrosion", + provider="RoundTrip", + start_date=date(2026, 3, 1), + end_date=date(2030, 3, 1), + mileage_limit_km=Decimal("80000"), + coverage_details="perforation only", + policy_number="RT-9", + notes="keep me", + ) + ) + await db_session.commit() + + exported = await client.get( + f"/api/export/vehicles/{vin}/warranties/csv", headers=auth_headers + ) + assert exported.status_code == 200 + + await db_session.execute(WarrantyRecord.__table__.delete().where(WarrantyRecord.vin == vin)) + await db_session.commit() + + r = await client.post( + f"/api/import/vehicles/{vin}/warranties/csv", + files={"file": ("w.csv", exported.text, "text/csv")}, + headers=auth_headers, + ) + assert r.status_code == 200, r.text + assert r.json()["error_count"] == 0, r.json() + # Derived, not hardcoded: the suite shares one database and one VIN, so + # earlier tests in this file have added warranties to the same vehicle + # and the export carries them too. Every exported row must be ACCOUNTED + # for -- imported or deduped -- and none may error. The parametrized + # units test above writes two rows with the same (provider, start_date), + # which is the importer's dedup key, so one of them is legitimately + # skipped and asserting on success_count alone would be wrong. + body = r.json() + _, exported_rows = _csv_rows(exported.text) + assert body["success_count"] + body["skipped_count"] == len(exported_rows), body + assert body["success_count"] > 0, body + + back = ( + await db_session.execute( + select(WarrantyRecord).where(WarrantyRecord.provider == "RoundTrip") + ) + ).scalar_one() + assert back.coverage_details == "perforation only" + assert back.policy_number == "RT-9" + assert back.mileage_limit_km == Decimal("80000") + assert back.end_date == date(2030, 3, 1) + + +@pytest.mark.asyncio +class TestInsuranceCsv: + async def test_export_does_not_500( + self, client: AsyncClient, db_session: AsyncSession, test_vehicle, auth_headers + ): + vin = str(test_vehicle["vin"]) + db_session.add( + InsurancePolicy( + vin=vin, + provider="Ins", + policy_number="P-1", + policy_type="Full Coverage", + start_date=date(2026, 1, 1), + end_date=date(2027, 1, 1), + premium_amount=Decimal("123.45"), + premium_frequency="Monthly", + deductible=Decimal("500"), + ) + ) + await db_session.commit() + + r = await client.get(f"/api/export/vehicles/{vin}/insurance/csv", headers=auth_headers) + assert r.status_code == 200, r.text + headers, rows = _csv_rows(r.text) + assert "Premium" in headers and "Premium Frequency" in headers + row = next(x for x in rows if x[headers.index("Policy Number")] == "P-1") + assert row[headers.index("Premium")] == "123.45" + assert row[headers.index("Premium Frequency")] == "Monthly" + + async def test_round_trip( + self, client: AsyncClient, db_session: AsyncSession, test_vehicle, auth_headers + ): + vin = str(test_vehicle["vin"]) + db_session.add( + InsurancePolicy( + vin=vin, + provider="InsRT", + policy_number="P-RT", + policy_type="Liability", + start_date=date(2026, 4, 1), + end_date=date(2027, 4, 1), + premium_amount=Decimal("99.00"), + premium_frequency="Annual", + deductible=Decimal("250"), + coverage_limits="100/300", + ) + ) + await db_session.commit() + + exported = await client.get( + f"/api/export/vehicles/{vin}/insurance/csv", headers=auth_headers + ) + assert exported.status_code == 200 + await db_session.execute( + InsurancePolicy.__table__.delete().where(InsurancePolicy.vin == vin) + ) + await db_session.commit() + + r = await client.post( + f"/api/import/vehicles/{vin}/insurance/csv", + files={"file": ("i.csv", exported.text, "text/csv")}, + headers=auth_headers, + ) + assert r.status_code == 200, r.text + assert r.json()["error_count"] == 0, r.json() + + back = ( + await db_session.execute( + select(InsurancePolicy).where(InsurancePolicy.provider == "InsRT") + ) + ).scalar_one() + assert back.premium_amount == Decimal("99.00") + assert back.premium_frequency == "Annual" + assert back.deductible == Decimal("250") + + +@pytest.mark.asyncio +class TestTaxCsv: + async def test_round_trip( + self, client: AsyncClient, db_session: AsyncSession, test_vehicle, auth_headers + ): + """The one both earlier revisions missed. + + Tax export worked, so the bug was invisible from the export side. No + tax record has ever imported: the constructor used four attributes the + model does not have, and the importer read four headers the exporter + does not write. + """ + vin = str(test_vehicle["vin"]) + db_session.add( + TaxRecord( + vin=vin, + date=date(2026, 5, 1), + tax_type="Registration", + amount=Decimal("212.00"), + renewal_date=date(2027, 5, 1), + notes="tax rt", + ) + ) + await db_session.commit() + + exported = await client.get(f"/api/export/vehicles/{vin}/tax/csv", headers=auth_headers) + assert exported.status_code == 200 + await db_session.execute(TaxRecord.__table__.delete().where(TaxRecord.vin == vin)) + await db_session.commit() + + r = await client.post( + f"/api/import/vehicles/{vin}/tax/csv", + files={"file": ("t.csv", exported.text, "text/csv")}, + headers=auth_headers, + ) + assert r.status_code == 200, r.text + assert r.json()["error_count"] == 0, r.json() + # Same accounting as the warranty round trip: the shared VIN carries + # other tests' tax records, and the fixture data contains genuine + # (date, tax_type) duplicates, which the importer's dedup key skips. + # Every exported row must be accounted for; none may error. + body = r.json() + _, exported_rows = _csv_rows(exported.text) + assert body["success_count"] + body["skipped_count"] == len(exported_rows), body + assert body["success_count"] > 0, body + + back = ( + await db_session.execute( + select(TaxRecord).where(TaxRecord.vin == vin, TaxRecord.notes == "tax rt") + ) + ).scalar_one() + assert back.date == date(2026, 5, 1) + assert back.tax_type == "Registration" + assert back.amount == Decimal("212.00") + assert back.renewal_date == date(2027, 5, 1) + + +@pytest.mark.asyncio +class TestALegacyFileIsNotSilentlySwallowed: + async def test_old_headers_report_the_real_problem( + self, client: AsyncClient, test_vehicle, auth_headers + ): + """A pre-fix file must not come back 200 with every row blamed on the user. + + Asserted on `error_count`, not on the status code: 200 with + `error_count == 1` and the message "Invalid record data" is exactly the + current behaviour, and it must fail this test. + """ + vin = str(test_vehicle["vin"]) + legacy = ( + "Provider,Type,Coverage,Start Date,End Date,Cost,Deductible,Max Claims,Terms,Notes\n" + "Legacy,Powertrain,old coverage,2026-01-01,2031-01-01,10.00,50.00,3,terms,n\n" + ) + r = await client.post( + f"/api/import/vehicles/{vin}/warranties/csv", + files={"file": ("legacy.csv", legacy, "text/csv")}, + headers=auth_headers, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["error_count"] == 0, ( + f"a legacy file must be read, not blamed on the user: {body}" + ) + assert body["success_count"] == 1 diff --git a/backend/tests/integration/test_reminder_notified_at_naive.py b/backend/tests/integration/test_reminder_notified_at_naive.py new file mode 100644 index 00000000..3b9f1590 --- /dev/null +++ b/backend/tests/integration/test_reminder_notified_at_naive.py @@ -0,0 +1,113 @@ +"""`last_notified_at` is a naive column and must be written naive. + +`check_due_reminders` builds `now = datetime.now(UTC)` (aware) and assigns it to +`reminder.last_notified_at`, declared `DateTime` with no timezone +(`models/reminder.py:40`). + +SQLite accepts that silently, which is why no dev instance has ever shown it. +PostgreSQL raises asyncpg `DataError: invalid input for query argument ... +can't subtract offset-naive and offset-aware datetimes` on the very first +reminder notification, so nobody running PostgreSQL has ever received one. + +The READ path was already guarded (`reminder_service.py:370-372` re-attaches +UTC to the naive value it gets back). Only the write was not, which is the +tell: someone hit the symptom on the read side and fixed the half they saw. + +This file lives in `tests/integration/` on purpose. `ci.yml:25` sets +`pg-migrations-pytest-path: "tests/migrations/ tests/integration/"`, so only +these two paths run against the PostgreSQL sidecar. The natural home would be +`tests/unit/services/test_reminder_service.py`, where it would pass on SQLite +with the bug fully present and stay green forever. +""" + +from datetime import date, timedelta + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.reminder import Reminder + + +@pytest.mark.asyncio +class TestLastNotifiedAtIsWrittenNaive: + async def test_a_due_reminder_can_be_marked_notified( + self, db_session: AsyncSession, test_vehicle: dict[str, object] + ) -> None: + """The regression. On PostgreSQL this raised before the row was written.""" + from app.services.reminder_service import check_due_reminders + + reminder = Reminder( + vin=str(test_vehicle["vin"]), + title="Naive datetime guard", + reminder_type="date", + due_date=date.today() - timedelta(days=1), + status="pending", + ) + db_session.add(reminder) + await db_session.commit() + await db_session.refresh(reminder) + + await check_due_reminders(db_session) + + stored = ( + await db_session.execute(select(Reminder).where(Reminder.id == reminder.id)) + ).scalar_one() + await db_session.refresh(stored) + assert stored.last_notified_at is not None, ( + "the reminder was due and should have been stamped as notified" + ) + assert stored.last_notified_at.tzinfo is None, ( + "last_notified_at is a naive column; an aware value must be stripped " + "before assignment, not handed to the driver" + ) + + async def test_the_cooldown_still_works_after_a_notification( + self, db_session: AsyncSession, test_vehicle: dict[str, object] + ) -> None: + """The read guard and the write must agree. + + Written because stripping tzinfo on write could plausibly be "fixed" by + making `now` naive throughout, which would break the aware comparison at + `:370-372` on the next tick. This asserts the second tick is a no-op + rather than a TypeError. + """ + from app.services.reminder_service import check_due_reminders + + reminder = Reminder( + vin=str(test_vehicle["vin"]), + title="Cooldown guard", + reminder_type="date", + due_date=date.today() - timedelta(days=1), + status="pending", + ) + db_session.add(reminder) + await db_session.commit() + + await check_due_reminders(db_session) + await db_session.refresh(reminder) + first = reminder.last_notified_at + assert first is not None + + # Second tick, inside the 24h cooldown: must not raise, must not restamp. + await check_due_reminders(db_session) + await db_session.refresh(reminder) + assert reminder.last_notified_at == first + + async def test_now_is_still_aware_where_it_is_compared(self) -> None: + """Pins the shape of the fix. + + The column is naive; the comparison against `NOTIFICATION_COOLDOWN` is + aware. Both are true at once, and a fix that makes `now` naive + everywhere would pass the first test and silently reintroduce the + TypeError this module's read guard exists to prevent. + """ + import inspect + + from app.services import reminder_service + + src = inspect.getsource(reminder_service.check_due_reminders) + assert "datetime.now(UTC)" in src, ( + "the comparison clock must stay timezone-aware; only the value " + "assigned to the naive column is stripped" + ) diff --git a/backend/tests/integration/test_session_concurrency.py b/backend/tests/integration/test_session_concurrency.py new file mode 100644 index 00000000..2a20826f --- /dev/null +++ b/backend/tests/integration/test_session_concurrency.py @@ -0,0 +1,164 @@ +"""Two concurrent first-movement payloads create ONE session, not two. + +MQTT and HTTPS ingest can genuinely race: both read `livelink_devices. +current_session_id`, both find NULL, and both create. One wins the pointer and +the other is orphaned OPEN forever -- and an open session is never closed by any +later path, because every closing clock starts from the pointer. + +Two mechanisms stop it, and they are load-bearing in different ways: + +- a **row lock** on the device through session creation, so the common case + serialises rather than racing; +- the **partial unique index** `uq_drive_sessions_open_per_device`, which makes + it a constraint rather than a convention. + +Lives in `tests/integration/` deliberately. This is one of the two paths CI runs +under PostgreSQL (`pg-migrations-pytest-path: "tests/migrations/ +tests/integration/"`), and PostgreSQL is where both mechanisms actually +function: `SELECT ... FOR UPDATE` is a no-op under SQLite's single writer, so a +SQLite-only run would exercise neither and pass regardless. + +It needs two real connections, so it builds its own sessions rather than using +the shared `db_session` fixture -- one session cannot race itself. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta + +import pytest +from sqlalchemy import func, select + +from app.models.drive_session import DriveSession +from app.models.livelink_device import LiveLinkDevice +from app.models.user import User +from app.models.vehicle import Vehicle +from app.services.session_service import SessionService + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + +T0 = datetime(2026, 9, 1, 8, 0, 0) +MOVING = {"SPEED": 48.0, "ENGINE_RPM": 2100.0} + + +async def _seed(db_session, prefix: str) -> tuple[str, str]: + """A committed user/vehicle/device the concurrent sessions can both see.""" + user = User( + username=f"{prefix}_user", + email=f"{prefix}@example.com", + hashed_password="x", + is_active=True, + is_admin=False, + ) + db_session.add(user) + await db_session.flush() + + vin = f"{prefix.upper()}00000000000"[:17] + db_session.add(Vehicle(vin=vin, user_id=user.id, nickname=prefix, vehicle_type="Car")) + await db_session.flush() + + device_id = f"{prefix}dev"[:20] + db_session.add( + LiveLinkDevice(device_id=device_id, vin=vin, enabled=True, kind="wican", last_seen=T0) + ) + await db_session.commit() + return vin, device_id + + +async def _confirm_movement(sessionmaker, device_id: str, at: datetime) -> None: + """One ingest path's worth of work, in its own transaction. + + Both above-floor samples are fed here so the debounce is satisfied inside + this transaction -- the race being tested is over CREATION, and splitting + the debounce across the two racers would just serialise them by accident. + """ + async with sessionmaker() as db: + device = ( + await db.execute(select(LiveLinkDevice).where(LiveLinkDevice.device_id == device_id)) + ).scalar_one() + service = SessionService(db) + await service.observe_telemetry(device, MOVING, at, live=True) + await service.observe_telemetry(device, MOVING, at + timedelta(seconds=30), live=True) + await db.commit() + + +class TestConcurrentFirstMovement: + async def test_two_racing_payloads_leave_one_open_session(self, db_session, test_sessionmaker): + vin, device_id = await _seed(db_session, "conc1") + + # Both racers run against the same device, from separate connections. + # One of the two may lose on the constraint; that is the mechanism + # working, so the exception is captured rather than failing the test. + results = await asyncio.gather( + _confirm_movement(test_sessionmaker, device_id, T0), + _confirm_movement(test_sessionmaker, device_id, T0 + timedelta(seconds=1)), + return_exceptions=True, + ) + + async with test_sessionmaker() as db: + open_count = ( + await db.execute( + select(func.count(DriveSession.id)) + .where(DriveSession.device_id == device_id) + .where(DriveSession.ended_at.is_(None)) + ) + ).scalar() + total = ( + await db.execute( + select(func.count(DriveSession.id)).where(DriveSession.device_id == device_id) + ) + ).scalar() + + # NEITHER racer may raise. This is what the row lock buys, and without + # it the assertion fails while every other assertion here still passes: + # the index keeps the DATA correct (one open session) while the losing + # payload takes an IntegrityError, which for a WiCAN is a dropped + # reading. Measured -- the first version of the lock did not re-read the + # pointer under it, so the loser's identity-mapped device still held a + # stale NULL and it created anyway. + raised = [r for r in results if isinstance(r, BaseException)] + assert raised == [], ( + f"a racing ingest payload failed instead of adopting the session " + f"the other one opened: {raised}" + ) + + assert open_count == 1, ( + f"{open_count} open sessions for one device. A second open session is " + f"orphaned forever: every closing clock starts from the device pointer, " + f"which only one of them holds. Racer outcomes: {results}" + ) + assert total == 1, ( + f"{total} sessions created for one drive; both racers wrote. Racer outcomes: {results}" + ) + + async def test_the_pointer_names_the_surviving_session(self, db_session, test_sessionmaker): + """An open session the pointer does NOT name is the orphan case. + + Counting open sessions alone would pass if the winner's row survived + while the pointer pointed at the loser's rolled-back id. + """ + vin, device_id = await _seed(db_session, "conc2") + + await asyncio.gather( + _confirm_movement(test_sessionmaker, device_id, T0), + _confirm_movement(test_sessionmaker, device_id, T0 + timedelta(seconds=1)), + return_exceptions=True, + ) + + async with test_sessionmaker() as db: + device = ( + await db.execute( + select(LiveLinkDevice).where(LiveLinkDevice.device_id == device_id) + ) + ).scalar_one() + open_session = ( + await db.execute( + select(DriveSession) + .where(DriveSession.device_id == device_id) + .where(DriveSession.ended_at.is_(None)) + ) + ).scalar_one_or_none() + + assert open_session is not None + assert device.current_session_id == open_session.id diff --git a/backend/tests/integration/tools/__init__.py b/backend/tests/integration/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/integration/tools/test_backfill_tool_dialects.py b/backend/tests/integration/tools/test_backfill_tool_dialects.py new file mode 100644 index 00000000..c6545af1 --- /dev/null +++ b/backend/tests/integration/tools/test_backfill_tool_dialects.py @@ -0,0 +1,151 @@ +"""The odometer repair tool must run to completion on the live dialect. + +This lives in `tests/integration/` deliberately. `ci.yml:25` sets +`pg-migrations-pytest-path: "tests/migrations/ tests/integration/"`, so only +tests under those two paths are executed against the PostgreSQL sidecar. The +natural home for a tools test is `tests/unit/tools/`, which runs on SQLite +only -- and on SQLite this test passes with the bug fully present, because +SQLite returns every DATE as a string. Placed there it would have been green, +in CI, forever, while the tool remained unusable on PostgreSQL. + +The defect: `date.fromisoformat(row.day)` raises +`TypeError: fromisoformat: argument must be str` on the first telemetry row, +because psycopg2 adapts PostgreSQL `DATE` to `datetime.date`. +""" + +import sys +import uuid +from datetime import UTC, date, datetime, timedelta + +import pytest +import pytest_asyncio +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.utils.db_url import to_sync_url + + +def _sync_url() -> str: + """The URL the tool should be pointed at, matching the suite's database.""" + from tests.conftest import TEST_DATABASE_URL + + return to_sync_url(TEST_DATABASE_URL) + + +def _device_id(vin: str) -> str: + """`livelink_devices.device_id` is VARCHAR(20), which PostgreSQL enforces and + SQLite does not. A longer id passes locally and fails only in the PG job.""" + return f"d-{vin[-12:]}" + + +@pytest_asyncio.fixture +async def own_vehicle(db_session: AsyncSession, test_user: dict[str, object]) -> str: + """A vehicle owned by this test alone, returned as its VIN. + + The suite shares one database with no per-test rollback, and `test_vehicle` + hands every test the same fixed VIN. Two tests here both seed odometer + telemetry, so sharing a VIN would let the first test's rows change the + second test's result -- and the direction of that leak (extra telemetry, + already-taken days) is exactly what these tests assert on. + """ + from app.models.vehicle import Vehicle + + vin = f"TOOLTEST{uuid.uuid4().hex[:9].upper()}" + db_session.add( + Vehicle( + vin=vin, + user_id=test_user["id"], + nickname="Tool test", + vehicle_type="Car", + year=2018, + make="Honda", + model="Accord", + ) + ) + await db_session.commit() + return vin + + +@pytest.mark.asyncio +class TestBackfillToolRunsOnThisDialect: + """End-to-end, against whichever database the suite is running on.""" + + async def _seed(self, db_session: AsyncSession, vin: str) -> None: + """One device and two days of odometer telemetry, in miles. + + Seeded through the ORM rather than raw INSERTs so the fixture does not + have to track every NOT NULL column added to these tables later. + """ + from app.models.livelink_device import LiveLinkDevice + from app.models.vehicle_telemetry import VehicleTelemetry + + db_session.add(LiveLinkDevice(device_id=_device_id(vin), vin=vin, odometer_unit="mi")) + base = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=3) + for offset, value in ((0, 1000.0), (1, 1050.0)): + db_session.add( + VehicleTelemetry( + vin=vin, + device_id=_device_id(vin), + param_key="ODOMETER", + value=value, + timestamp=base + timedelta(days=offset), + ) + ) + await db_session.commit() + + async def test_dry_run_completes_instead_of_raising_on_a_date_column( + self, db_session: AsyncSession, own_vehicle: str, monkeypatch + ) -> None: + """The regression. On PostgreSQL this raised TypeError on the first row. + + Asserts the exit code rather than the written records, because the + failure being guarded is that the tool cannot finish reading at all. + """ + from tools.backfill_livelink_odometer import main + + vin = own_vehicle + await self._seed(db_session, vin) + + monkeypatch.setattr( + sys, "argv", ["backfill_livelink_odometer.py", "--db", _sync_url(), "--vin", vin] + ) + assert main() == 0 + + async def test_it_skips_a_day_that_already_has_a_record( + self, db_session: AsyncSession, own_vehicle: str, monkeypatch + ) -> None: + """Reads `odometer_records.date`, the second DATE column the tool parses. + + The first test only reaches `date(timestamp)`; the `taken` and `existing` + lookups are a separate pair of call sites and would still have been + broken on PostgreSQL with only the first test in place. + """ + from tools.backfill_livelink_odometer import main + + vin = own_vehicle + await self._seed(db_session, vin) + from app.models.odometer import OdometerRecord + + db_session.add( + OdometerRecord( + vin=vin, + date=(datetime.now(UTC) - timedelta(days=3)).date(), + odometer_km=5000, + source="manual", + ) + ) + await db_session.commit() + + monkeypatch.setattr( + sys, "argv", ["backfill_livelink_odometer.py", "--db", _sync_url(), "--vin", vin] + ) + assert main() == 0 + + rows = ( + await db_session.execute( + text("SELECT date, source FROM odometer_records WHERE vin = :v"), {"v": vin} + ) + ).all() + # Dry run: nothing written, and the human's record is untouched. + assert [r.source for r in rows] == ["manual"] + assert isinstance(rows[0].date, date | str) diff --git a/backend/tests/integration/tools/test_upgrade_procedure.py b/backend/tests/integration/tools/test_upgrade_procedure.py new file mode 100644 index 00000000..85b0efc0 --- /dev/null +++ b/backend/tests/integration/tools/test_upgrade_procedure.py @@ -0,0 +1,158 @@ +"""The four-tool upgrade procedure, run the way the CHANGELOG tells people to. + +The CHANGELOG prescribes an exact sequence: + + docker exec -w /app mygarage python tools/backfill_livelink_odometer.py --apply + docker exec -w /app mygarage python tools/normalize_telemetry_odometer_units.py --apply + docker exec -w /app mygarage python tools/fix_session_odometer_units.py --apply + docker exec -w /app mygarage python tools/recompute_session_aggregates.py --apply + +Nothing exercised that. The unit tests covered URL resolution and exit codes, +which is why `backfill_livelink_odometer.py` shipped calling +`date.fromisoformat()` on a PostgreSQL DATE and dying on the first row: every +test that touched it either used SQLite (where a DATE is a string) or never gave +it a row to read. + +So these run the tools as **subprocesses**, in the documented order, with no +`--db`, against whichever database the suite is using. That is the real +invocation: it exercises `sys.path.insert(0, ".")`, argparse, the +`settings.database_url` fallback, and the driver's actual return types. + +Dry run only. `--apply` is deliberately not tested here: these tools mutate +odometer history, and a test that writes into the shared suite database would +change what every later test sees. What is being guarded is that each tool can +*read* the live schema on this dialect and reach its own exit, which is the +class of failure that shipped. +""" + +import os +import subprocess +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession + +from app.utils.db_url import to_sync_url + +BACKEND_ROOT = Path(__file__).resolve().parents[3] + +#: The published order. Index 0 must run before index 1: the odometer backfill +#: reads telemetry as the device reported it, so it has to run while the +#: history is still device-native. +UPGRADE_SEQUENCE = ( + "backfill_livelink_odometer.py", + "normalize_telemetry_odometer_units.py", + "fix_session_odometer_units.py", + "recompute_session_aggregates.py", +) + + +def _run_tool(name: str) -> subprocess.CompletedProcess[str]: + """Run one tool exactly as the CHANGELOG says, in a clean subprocess.""" + from tests.conftest import TEST_DATABASE_URL + + env = { + **os.environ, + "PYTHONPATH": str(BACKEND_ROOT), + # What `docker exec` sees: the app's own configured database, no --db. + "MYGARAGE_DATABASE_URL": TEST_DATABASE_URL, + } + return subprocess.run( + [sys.executable, f"tools/{name}"], + cwd=BACKEND_ROOT, + env=env, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + + +@pytest_asyncio.fixture +async def a_vehicle_with_telemetry(db_session: AsyncSession, test_user: dict[str, object]) -> str: + """Enough real data that every tool has something to read. + + A tool with an empty table exits 0 without touching the code paths that + parse driver return values, so seeding is what makes these tests non-vacuous. + """ + import uuid + + from app.models.livelink_device import LiveLinkDevice + from app.models.odometer import OdometerRecord + from app.models.vehicle import Vehicle + from app.models.vehicle_telemetry import VehicleTelemetry + + vin = f"UPGRADE{uuid.uuid4().hex[:10].upper()}" + device_id = f"u-{vin[-12:]}" + db_session.add( + Vehicle( + vin=vin, + user_id=test_user["id"], + nickname="Upgrade test", + vehicle_type="Car", + year=2020, + make="Ram", + model="1500", + ) + ) + db_session.add(LiveLinkDevice(device_id=device_id, vin=vin, odometer_unit="mi")) + base = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=5) + for offset, value in enumerate((1000.0, 1050.0, 1100.0)): + db_session.add( + VehicleTelemetry( + vin=vin, + device_id=device_id, + param_key="ODOMETER", + value=value, + timestamp=base + timedelta(days=offset), + ) + ) + db_session.add(OdometerRecord(vin=vin, date=base.date(), odometer_km=1609, source="manual")) + await db_session.commit() + return vin + + +@pytest.mark.asyncio +class TestUpgradeProcedure: + """Every tool in the published sequence runs to completion on this dialect.""" + + async def test_the_seed_is_visible_to_a_separate_connection( + self, a_vehicle_with_telemetry: str + ) -> None: + """Guards the guard. + + The tools run in their own process on their own connection. If the + fixture's rows were not committed, every tool below would find an empty + table, exit 0, and prove nothing -- while looking green. + """ + from sqlalchemy import create_engine, text + + from tests.conftest import TEST_DATABASE_URL + + engine = create_engine(to_sync_url(TEST_DATABASE_URL)) + with engine.connect() as conn: + count = conn.execute( + text("SELECT COUNT(*) FROM vehicle_telemetry WHERE vin = :v"), + {"v": a_vehicle_with_telemetry}, + ).scalar_one() + engine.dispose() + assert count == 3 + + @pytest.mark.parametrize("tool", UPGRADE_SEQUENCE) + async def test_tool_runs_to_completion(self, tool: str, a_vehicle_with_telemetry: str) -> None: + """A dry run exits cleanly rather than raising on a driver return type. + + Exit code 2 is a legitimate refusal ("mixed units present, run the other + tool first") and is accepted. A traceback is not. + """ + result = _run_tool(tool) + assert "Traceback" not in result.stderr, ( + f"{tool} raised on this dialect:\n{result.stderr[-2000:]}" + ) + assert result.returncode in (0, 2), ( + f"{tool} exited {result.returncode}\n" + f"stdout:\n{result.stdout[-2000:]}\nstderr:\n{result.stderr[-2000:]}" + ) diff --git a/backend/tests/migrations/test_097_tire_mount_periods.py b/backend/tests/migrations/test_097_tire_mount_periods.py new file mode 100644 index 00000000..bf41dd57 --- /dev/null +++ b/backend/tests/migrations/test_097_tire_mount_periods.py @@ -0,0 +1,566 @@ +"""Migration 097: tire mount periods, nullable position, retired_on. + +Every test builds a throwaway SQLite database from scratch, so these are +independent of the shared test database. + +The hazard this migration is built around is worth restating, because it is +silent: `PRAGMA foreign_keys = OFF` is a **no-op inside a transaction** and +SQLite reports no error -- the pragma read still returns 1. A rebuild that +assumes it worked fires `ON DELETE CASCADE` on `DROP TABLE tires` and takes +every `tire_readings` row with it, leaving a schema that looks correct and a +database with no tread history. `test_the_cascade_hazard_is_real` demonstrates +it rather than describing it. +""" + +from __future__ import annotations + +import importlib.util +import sqlite3 +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, inspect, text + +_MIGRATION = ( + Path(__file__).parent.parent.parent / "app" / "migrations" / "097_tire_mount_periods.py" +) + + +def _load_migration(): + spec = importlib.util.spec_from_file_location("m097", _MIGRATION) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _legacy_schema(path: Path) -> None: + """The v3.2.0 shape of every table 097 touches.""" + conn = sqlite3.connect(path) + conn.executescript(""" + CREATE TABLE vehicles ( + vin VARCHAR(17) PRIMARY KEY, + nickname VARCHAR(100) + ); + CREATE TABLE service_line_items (id INTEGER PRIMARY KEY); + CREATE TABLE tires ( + id INTEGER NOT NULL, + vin VARCHAR(17) NOT NULL, + position VARCHAR(10) NOT NULL, + brand VARCHAR(80), + model_name VARCHAR(80), + size VARCHAR(40), + dot_code VARCHAR(20), + installed_date DATE, + tread_depth_mm NUMERIC(5, 2), + pressure_kpa NUMERIC(7, 2), + min_tread_mm NUMERIC(5, 2), + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uq_tires_vin_position UNIQUE (vin, position), + FOREIGN KEY(vin) REFERENCES vehicles (vin) ON DELETE CASCADE + ); + CREATE INDEX idx_tires_vin ON tires (vin); + CREATE TABLE tire_readings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tire_id INTEGER NOT NULL, + vin VARCHAR(17) NOT NULL, + position VARCHAR(10) NOT NULL, + recorded_at DATE NOT NULL, + odometer_km NUMERIC(10, 2), + tread_depth_mm NUMERIC(5, 2), + pressure_kpa NUMERIC(7, 2), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (tire_id) REFERENCES tires(id) ON DELETE CASCADE, + FOREIGN KEY (vin) REFERENCES vehicles(vin) ON DELETE CASCADE + ); + CREATE INDEX idx_tire_readings_tire ON tire_readings (tire_id); + CREATE INDEX idx_tire_readings_vin ON tire_readings (vin); + CREATE TABLE vehicle_reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vin VARCHAR(17) NOT NULL REFERENCES vehicles(vin) ON DELETE CASCADE, + line_item_id INTEGER REFERENCES service_line_items(id) ON DELETE SET NULL, + title VARCHAR(200) NOT NULL, + reminder_type VARCHAR(10) NOT NULL + CHECK (reminder_type IN ('date','mileage','both','smart','hours')), + due_date DATE, + due_mileage_km NUMERIC(10,2) + CHECK (due_mileage_km IS NULL OR due_mileage_km > 0), + due_hours NUMERIC(10,1), + status VARCHAR(10) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','done','dismissed')), + notes TEXT, + last_notified_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX ix_reminders_vin_status ON vehicle_reminders (vin, status); + CREATE INDEX ix_reminders_due_date ON vehicle_reminders (due_date); + CREATE INDEX ix_reminders_due_mileage_km ON vehicle_reminders (due_mileage_km); + INSERT INTO vehicles (vin, nickname) VALUES ('VINAAA00000000001', 'A'); + INSERT INTO vehicles (vin, nickname) VALUES ('VINBBB00000000002', 'B'); + """) + conn.commit() + conn.close() + + +def _seed_tire( + path: Path, + *, + tire_id: int, + vin: str, + position: str, + installed: str | None = None, + readings: int = 1, +) -> None: + conn = sqlite3.connect(path) + conn.execute( + "INSERT INTO tires (id, vin, position, installed_date, tread_depth_mm, " + "min_tread_mm) VALUES (?, ?, ?, ?, 8.0, 2.0)", + (tire_id, vin, position, installed), + ) + for n in range(readings): + conn.execute( + "INSERT INTO tire_readings (tire_id, vin, position, recorded_at, " + "tread_depth_mm) VALUES (?, ?, ?, ?, ?)", + (tire_id, vin, position, f"2026-0{n + 1}-01", 8.0 - n), + ) + conn.commit() + conn.close() + + +@pytest.fixture +def legacy_db(tmp_path: Path) -> Path: + path = tmp_path / "legacy.db" + _legacy_schema(path) + return path + + +class TestTheMigrationPreservesData: + def test_readings_survive_the_tires_rebuild(self, legacy_db: Path): + """The cascade hazard, asserted on the outcome. + + `DROP TABLE tires` in step 3 will fire `ON DELETE CASCADE` on + `tire_readings` unless FK enforcement is genuinely off. Two readings in, + two readings out. + """ + _seed_tire(legacy_db, tire_id=1, vin="VINAAA00000000001", position="FL", readings=2) + _load_migration().upgrade(create_engine(f"sqlite:///{legacy_db}")) + + conn = sqlite3.connect(legacy_db) + assert conn.execute("SELECT COUNT(*) FROM tire_readings").fetchone()[0] == 2 + conn.close() + + def test_the_cascade_hazard_is_real(self, tmp_path: Path): + """Demonstrates WHY the pragma read-back exists, rather than asserting + that the code contains it. + + With FK enforcement on, dropping the parent silently empties the child. + Inside a transaction the pragma cannot be turned off, and reading it + back still reports 1 -- so a migration that sets it and trusts the set + loses the data with no error anywhere. + """ + path = tmp_path / "hazard.db" + _legacy_schema(path) + _seed_tire(path, tire_id=1, vin="VINAAA00000000001", position="FL", readings=2) + + conn = sqlite3.connect(path) + conn.execute("PRAGMA foreign_keys = ON") + assert conn.execute("SELECT COUNT(*) FROM tire_readings").fetchone()[0] == 2 + + conn.execute("BEGIN") + conn.execute("PRAGMA foreign_keys = OFF") # a no-op inside a transaction + assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1, ( + "the pragma reports success while having done nothing, which is the " + "whole reason 097 sets it on a raw connection outside any transaction" + ) + conn.execute("DROP TABLE tires") + assert conn.execute("SELECT COUNT(*) FROM tire_readings").fetchone()[0] == 0, ( + "expected the cascade to empty tire_readings; if this ever stops " + "being true the read-back guard could be relaxed" + ) + conn.close() + + def test_reminders_survive_and_gain_their_columns(self, legacy_db: Path): + conn = sqlite3.connect(legacy_db) + conn.execute( + "INSERT INTO vehicle_reminders (vin, title, reminder_type, status) " + "VALUES ('VINAAA00000000001', 'Keep me', 'date', 'pending')" + ) + conn.commit() + conn.close() + + _load_migration().upgrade(create_engine(f"sqlite:///{legacy_db}")) + + conn = sqlite3.connect(legacy_db) + rows = conn.execute("SELECT title, tire_id, source FROM vehicle_reminders").fetchall() + assert rows == [("Keep me", None, None)] + conn.close() + + def test_an_assumed_period_is_created_per_mounted_tire(self, legacy_db: Path): + _seed_tire( + legacy_db, tire_id=1, vin="VINAAA00000000001", position="FL", installed="2024-03-01" + ) + _seed_tire(legacy_db, tire_id=2, vin="VINAAA00000000001", position="FR") + _load_migration().upgrade(create_engine(f"sqlite:///{legacy_db}")) + + conn = sqlite3.connect(legacy_db) + periods = conn.execute( + "SELECT tire_id, position, mounted_on, is_assumed, observed_active_on " + "FROM tire_mount_periods ORDER BY tire_id" + ).fetchall() + assert len(periods) == 2 + # The tire that HAD an installed_date keeps it as the period start. + assert periods[0][:4] == (1, "FL", "2024-03-01", 1) + # The one that did not gets a NULL start -- unknown, not invented. That + # is what makes distance_on_tire report `nothing_bounded` for it. + assert periods[1][:4] == (2, "FR", None, 1) + # Both record WHEN the assumption was made. + assert all(p[4] is not None for p in periods) + conn.close() + + def test_existing_readings_are_attached_to_the_assumed_period(self, legacy_db: Path): + _seed_tire(legacy_db, tire_id=1, vin="VINAAA00000000001", position="FL", readings=3) + _load_migration().upgrade(create_engine(f"sqlite:///{legacy_db}")) + + conn = sqlite3.connect(legacy_db) + unlinked = conn.execute( + "SELECT COUNT(*) FROM tire_readings WHERE mount_period_id IS NULL" + ).fetchone()[0] + assert unlinked == 0 + conn.close() + + +class TestPreflight: + def test_p1_repairs_a_child_whose_vin_disagrees_with_its_tire(self, legacy_db: Path): + """The composite FK makes this row impossible, so it must be repaired + BEFORE the FK is installed, not discovered by it.""" + _seed_tire(legacy_db, tire_id=1, vin="VINAAA00000000001", position="FL", readings=0) + conn = sqlite3.connect(legacy_db) + conn.execute( + "INSERT INTO tire_readings (tire_id, vin, position, recorded_at, " + "tread_depth_mm) VALUES (1, 'VINBBB00000000002', 'FL', '2026-01-01', 7.0)" + ) + conn.commit() + conn.close() + + _load_migration().upgrade(create_engine(f"sqlite:///{legacy_db}")) + + conn = sqlite3.connect(legacy_db) + # Repaired to the TIRE's vin, not deleted: the tire is the authority + # and the denormalised vin on the child is the copy. + assert conn.execute("SELECT vin FROM tire_readings").fetchall() == [("VINAAA00000000001",)] + conn.close() + + def test_p2_skips_a_violated_check_instead_of_crash_looping(self, legacy_db: Path): + """A FATAL migration that raises here does not fail once: the app + crash-loops on every restart until someone with database access repairs + the row by hand. A missing constraint is recoverable; that is not. + + The violating value is one the application can actually produce: the + legacy JSON importer derives `has_miles` from + `bool(is_recurring and recurrence_miles)`, which is true for a negative. + """ + conn = sqlite3.connect(legacy_db) + # Insert past the CHECK the same way a pre-053 database would carry it. + conn.execute("PRAGMA writable_schema = ON") + conn.execute( + "UPDATE sqlite_master SET sql = replace(sql, " + "'CHECK (due_mileage_km IS NULL OR due_mileage_km > 0)', '') " + "WHERE name = 'vehicle_reminders'" + ) + conn.execute("PRAGMA writable_schema = OFF") + conn.commit() + conn.close() + + conn = sqlite3.connect(legacy_db) + conn.execute( + "INSERT INTO vehicle_reminders (vin, title, reminder_type, status, " + "due_mileage_km) VALUES ('VINAAA00000000001', 'Negative', 'mileage', " + "'pending', -5)" + ) + conn.commit() + conn.close() + + # Must NOT raise. + _load_migration().upgrade(create_engine(f"sqlite:///{legacy_db}")) + + conn = sqlite3.connect(legacy_db) + sql = conn.execute( + "SELECT sql FROM sqlite_master WHERE name = 'vehicle_reminders'" + ).fetchone()[0] + # The two it CAN take are installed; the one it cannot is skipped. + assert "check_reminder_type" in sql + assert "check_reminder_status" in sql + assert "check_due_mileage_km" not in sql + # And the offending row survives, so it can be repaired. + assert ( + conn.execute( + "SELECT COUNT(*) FROM vehicle_reminders WHERE due_mileage_km = -5" + ).fetchone()[0] + == 1 + ) + conn.close() + + def test_all_three_checks_are_installed_when_the_data_is_clean(self, legacy_db: Path): + """The other direction. Without this, a P2 that skipped everything + would pass the test above.""" + _load_migration().upgrade(create_engine(f"sqlite:///{legacy_db}")) + conn = sqlite3.connect(legacy_db) + sql = conn.execute( + "SELECT sql FROM sqlite_master WHERE name = 'vehicle_reminders'" + ).fetchone()[0] + for name in ("check_reminder_type", "check_reminder_status", "check_due_mileage_km"): + assert name in sql, f"{name} missing from a clean upgrade" + conn.close() + + +class TestReEntrancy: + def test_running_twice_is_a_no_op(self, legacy_db: Path): + _seed_tire(legacy_db, tire_id=1, vin="VINAAA00000000001", position="FL", readings=2) + migration = _load_migration() + engine = create_engine(f"sqlite:///{legacy_db}") + + migration.upgrade(engine) + migration.upgrade(engine) + + conn = sqlite3.connect(legacy_db) + # One period, not two: the second run must not backfill again. + assert conn.execute("SELECT COUNT(*) FROM tire_mount_periods").fetchone()[0] == 1 + assert conn.execute("SELECT COUNT(*) FROM tire_readings").fetchone()[0] == 2 + conn.close() + + def test_a_fresh_create_all_database_is_left_alone(self, tmp_path: Path): + """The discriminator is `vehicle_reminders.source`, the LAST thing 097 + writes -- not `tire_mount_periods`, which `create_all` makes on every + upgrade before migrations run, and not `tires.installed_date`, which a + fresh database never had. + """ + from app.database import Base + + path = tmp_path / "fresh.db" + engine = create_engine(f"sqlite:///{path}") + Base.metadata.create_all(engine) + + _load_migration().upgrade(engine) + + conn = sqlite3.connect(path) + assert conn.execute("SELECT COUNT(*) FROM tire_mount_periods").fetchone()[0] == 0 + conn.close() + + +class TestSchemaParity: + """A fresh install and an upgraded one must end up with the same schema. + + Comparing `create_all` against `create_all` + a no-op 097 proves nothing. + The comparison that matters is a BARE `create_all` against + legacy + 097, which is the pair that can actually diverge. + """ + + @staticmethod + def _shape(engine) -> dict[str, set[str]]: + insp = inspect(engine) + shape = {} + for table in ("tires", "tire_readings", "tire_mount_periods", "tire_sets"): + shape[table] = {c["name"] for c in insp.get_columns(table)} + shape["vehicle_reminders"] = {c["name"] for c in insp.get_columns("vehicle_reminders")} + return shape + + def test_upgraded_columns_match_a_fresh_install(self, legacy_db: Path, tmp_path: Path): + from app.database import Base + + _seed_tire(legacy_db, tire_id=1, vin="VINAAA00000000001", position="FL") + upgraded = create_engine(f"sqlite:///{legacy_db}") + _load_migration().upgrade(upgraded) + + fresh_path = tmp_path / "fresh_parity.db" + fresh = create_engine(f"sqlite:///{fresh_path}") + Base.metadata.create_all(fresh) + + assert self._shape(upgraded) == self._shape(fresh) + + def test_the_open_period_index_exists_on_both_paths(self, legacy_db: Path, tmp_path: Path): + """A tire cannot be mounted in two places at once, and that has to be + true however the database was built.""" + from app.database import Base + + upgraded = create_engine(f"sqlite:///{legacy_db}") + _load_migration().upgrade(upgraded) + fresh_path = tmp_path / "fresh_idx.db" + fresh = create_engine(f"sqlite:///{fresh_path}") + Base.metadata.create_all(fresh) + + for engine in (upgraded, fresh): + names = {i["name"] for i in inspect(engine).get_indexes("tire_mount_periods")} + assert "uq_tire_single_open_period" in names + + def test_uq_tires_vin_position_survives_the_rebuild(self, legacy_db: Path): + """It is the only thing stopping two mounted tires sharing a corner, + and a SQLite rebuild keeps only what the new CREATE TABLE names.""" + _load_migration().upgrade(create_engine(f"sqlite:///{legacy_db}")) + conn = sqlite3.connect(legacy_db) + conn.execute("INSERT INTO tires (vin, position) VALUES ('VINAAA00000000001', 'FL')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO tires (vin, position) VALUES ('VINAAA00000000001', 'FL')") + conn.close() + + def test_two_stored_tires_can_coexist(self, legacy_db: Path): + """The property that actually CHANGED. NULLs compare as distinct under + UNIQUE, so a nullable `position` makes the same constraint mounted-only + without a partial-index replacement. + + Note this is asserted alongside the test above, not instead of it: on + its own it would also pass against a schema that dropped the constraint + entirely. + """ + _load_migration().upgrade(create_engine(f"sqlite:///{legacy_db}")) + conn = sqlite3.connect(legacy_db) + conn.execute("INSERT INTO tires (vin, position) VALUES ('VINAAA00000000001', NULL)") + conn.execute("INSERT INTO tires (vin, position) VALUES ('VINAAA00000000001', NULL)") + conn.commit() + assert conn.execute("SELECT COUNT(*) FROM tires WHERE position IS NULL").fetchone()[0] == 2 + conn.close() + + +_PG_LEGACY = """ +CREATE TABLE vehicles (vin VARCHAR(17) PRIMARY KEY, nickname VARCHAR(100)); +CREATE TABLE service_line_items (id SERIAL PRIMARY KEY); +CREATE TABLE tires ( + id SERIAL PRIMARY KEY, + vin VARCHAR(17) NOT NULL REFERENCES vehicles(vin) ON DELETE CASCADE, + position VARCHAR(10) NOT NULL, + brand VARCHAR(80), model_name VARCHAR(80), size VARCHAR(40), + dot_code VARCHAR(20), installed_date DATE, + tread_depth_mm NUMERIC(5,2), pressure_kpa NUMERIC(7,2), + min_tread_mm NUMERIC(5,2), notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT uq_tires_vin_position UNIQUE (vin, position) +); +CREATE INDEX idx_tires_vin ON tires (vin); +CREATE TABLE tire_readings ( + id SERIAL PRIMARY KEY, + tire_id INTEGER NOT NULL REFERENCES tires(id) ON DELETE CASCADE, + vin VARCHAR(17) NOT NULL REFERENCES vehicles(vin) ON DELETE CASCADE, + position VARCHAR(10) NOT NULL, + recorded_at DATE NOT NULL, + odometer_km NUMERIC(10,2), tread_depth_mm NUMERIC(5,2), + pressure_kpa NUMERIC(7,2), notes TEXT, + created_at TIMESTAMP DEFAULT now() +); +CREATE TABLE tire_sets ( + id SERIAL PRIMARY KEY, + vin VARCHAR(17) NOT NULL REFERENCES vehicles(vin) ON DELETE CASCADE, + name VARCHAR(60) NOT NULL, notes TEXT, + created_at TIMESTAMP DEFAULT now() +); +CREATE TABLE tire_mount_periods ( + id SERIAL PRIMARY KEY, + tire_id INTEGER NOT NULL REFERENCES tires(id) ON DELETE CASCADE, + position VARCHAR(10) NOT NULL, + mounted_on DATE, dismounted_on DATE, + mounted_odometer_km NUMERIC(10,2), dismounted_odometer_km NUMERIC(10,2), + is_assumed BOOLEAN NOT NULL DEFAULT false, + observed_active_on DATE, notes TEXT, + created_at TIMESTAMP DEFAULT now() +); +CREATE TABLE vehicle_reminders ( + id SERIAL PRIMARY KEY, + vin VARCHAR(17) NOT NULL REFERENCES vehicles(vin) ON DELETE CASCADE, + line_item_id INTEGER REFERENCES service_line_items(id) ON DELETE SET NULL, + title VARCHAR(200) NOT NULL, + reminder_type VARCHAR(10) NOT NULL, + due_date DATE, due_mileage_km NUMERIC(10,2), due_hours NUMERIC(10,1), + status VARCHAR(10) NOT NULL DEFAULT 'pending', + notes TEXT, last_notified_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); +CREATE INDEX ix_reminders_vin_status ON vehicle_reminders (vin, status); +CREATE INDEX ix_reminders_due_date ON vehicle_reminders (due_date); +CREATE INDEX ix_reminders_due_mileage_km ON vehicle_reminders (due_mileage_km); +INSERT INTO vehicles (vin, nickname) VALUES ('VINAAA00000000001', 'A'); +INSERT INTO tires (vin, position, installed_date) VALUES + ('VINAAA00000000001', 'FL', '2024-03-01'), + ('VINAAA00000000001', 'FR', NULL); +INSERT INTO tire_readings (tire_id, vin, position, recorded_at, tread_depth_mm) + SELECT id, vin, position, DATE '2026-01-01', 8.0 FROM tires; +INSERT INTO vehicle_reminders (vin, title, reminder_type, status) + VALUES ('VINAAA00000000001', 'Keep me', 'date', 'pending'); +""" + + +class TestPostgres: + """097's PostgreSQL branch, which is a different implementation. + + PostgreSQL has real `ALTER TABLE`, so nothing is rebuilt and none of the + SQLite rebuild machinery runs. That branch was previously exercised by + nothing at all: on PostgreSQL the suite's own database is built by + `create_all`, which already has the new shape, so 097 sees its own + discriminator and returns before touching anything. + """ + + def test_the_upgrade_runs_and_keeps_the_data(self, pg_engine): + with pg_engine.begin() as conn: + for statement in _PG_LEGACY.strip().split(";\n"): + if statement.strip(): + conn.execute(text(statement)) + + _load_migration().upgrade(pg_engine) + + insp = inspect(pg_engine) + tire_columns = {c["name"] for c in insp.get_columns("tires")} + assert "installed_date" not in tire_columns + assert {"set_id", "retired_on"} <= tire_columns + + reminder_columns = {c["name"] for c in insp.get_columns("vehicle_reminders")} + assert {"tire_id", "source", "tread_depth_mm"} <= reminder_columns + + with pg_engine.connect() as conn: + assert conn.execute(text("SELECT COUNT(*) FROM tire_readings")).scalar() == 2 + assert conn.execute(text("SELECT COUNT(*) FROM vehicle_reminders")).scalar() == 1 + periods = conn.execute( + text( + "SELECT position, mounted_on, is_assumed FROM tire_mount_periods " + "ORDER BY position" + ) + ).fetchall() + assert len(periods) == 2 + assert periods[0][1] is not None and periods[0][2] is True + # The tire with no installed_date gets a period with an unknown start. + assert periods[1][1] is None + + def test_position_becomes_nullable_and_stored_tires_coexist(self, pg_engine): + with pg_engine.begin() as conn: + for statement in _PG_LEGACY.strip().split(";\n"): + if statement.strip(): + conn.execute(text(statement)) + _load_migration().upgrade(pg_engine) + + # The NULL-distinct property this design depends on, on PostgreSQL too. + with pg_engine.begin() as conn: + conn.execute( + text("INSERT INTO tires (vin, position) VALUES ('VINAAA00000000001', NULL)") + ) + conn.execute( + text("INSERT INTO tires (vin, position) VALUES ('VINAAA00000000001', NULL)") + ) + with pg_engine.connect() as conn: + assert ( + conn.execute(text("SELECT COUNT(*) FROM tires WHERE position IS NULL")).scalar() + == 2 + ) + + def test_running_twice_is_a_no_op(self, pg_engine): + with pg_engine.begin() as conn: + for statement in _PG_LEGACY.strip().split(";\n"): + if statement.strip(): + conn.execute(text(statement)) + migration = _load_migration() + migration.upgrade(pg_engine) + migration.upgrade(pg_engine) + + with pg_engine.connect() as conn: + assert conn.execute(text("SELECT COUNT(*) FROM tire_mount_periods")).scalar() == 2 diff --git a/backend/tests/migrations/test_098_session_boundaries.py b/backend/tests/migrations/test_098_session_boundaries.py new file mode 100644 index 00000000..aa650c86 --- /dev/null +++ b/backend/tests/migrations/test_098_session_boundaries.py @@ -0,0 +1,517 @@ +"""Tests for migration 098: durable movement state and one open session per device. + +098 is the schema half of the session-boundary rework. It adds movement state to +``livelink_devices``, provenance to ``drive_sessions``, and -- the part with +teeth -- a **partial unique index** making "one open session per device" a +constraint rather than a convention. + +That index can fail on existing data, which is why the preflight is the bulk of +both the migration and this file. There are two ways a database gets a second +open row for one device, and an earlier draft of the design handled only the +first: + +1. **The race.** Two concurrent first-contact payloads (MQTT and HTTPS) both read + a NULL ``current_session_id``, both create. One wins the pointer; the other is + orphaned open forever. Closing "the older one" is wrong here: the pointer can + belong to the older row, so closing by timestamp closes the session the device + is actively writing to and keeps the orphan. +2. **The singleton orphan.** ``LiveLinkService.unlink_device`` clears + ``current_session_id`` without closing the session. That violates nothing + today -- it is one open row, not two -- so a duplicate scan does not see it, + and it would then reject every future session start for that device once the + index exists. Permanently, and with no message a user could act on. + +So the preflight is an inventory of every open row, not a duplicate scan. Each +case below is one row of that inventory. + +Parameterized over SQLite and PostgreSQL. PG matters specifically: partial index +syntax and ``NOT NULL DEFAULT`` behaviour on ``ALTER TABLE`` differ, and +``bin/ci-check`` does not run PG -- which is how the migration 096 defect reached +CI. + +Every test builds the PRE-098 shape by hand. The ORM now declares these columns, +so a ``create_all`` baseline would start in the post-state and every +"column exists" assertion would pass before the migration ran. +""" + +from __future__ import annotations + +import importlib.util +from datetime import datetime, timedelta +from pathlib import Path + +import pytest +from sqlalchemy import inspect, text + +import app.migrations as _m + +pytestmark = pytest.mark.migrations + + +def _load(name: str): + path = Path(_m.__file__).parent / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +#: (table, column, nullable). The whole set 098 adds, enumerated in one place so +#: a column added to the design without a matching migration step fails here. +#: An earlier revision of the design named this migration around ONE column +#: while the rest of the document silently required four more -- and on SQLite a +#: model that declares a column the table lacks does not self-heal, because +#: `create_all` never alters an existing table. +EXPECTED_COLUMNS = [ + ("livelink_devices", "last_movement_at", True), + ("livelink_devices", "pending_since", True), + ("livelink_devices", "pending_source", True), + ("livelink_devices", "movement_candidate_at", True), + ("livelink_devices", "movement_baseline_km", True), + ("drive_sessions", "movement_started_at", True), + ("drive_sessions", "movement_ended_at", True), + ("drive_sessions", "boundary_algorithm_version", False), + ("drive_sessions", "effective_gap_minutes", True), +] + +BASE = datetime(2026, 9, 1, 8, 0, 0) + + +def _make_pre_098_schema(engine) -> None: + """livelink_devices + drive_sessions + vehicle_telemetry, pre-098.""" + is_pg = engine.dialect.name == "postgresql" + pk = "SERIAL PRIMARY KEY" if is_pg else "INTEGER PRIMARY KEY AUTOINCREMENT" + with engine.begin() as conn: + conn.execute( + text(f""" + CREATE TABLE livelink_devices ( + id {pk}, + device_id VARCHAR(20) NOT NULL, + vin VARCHAR(17), + current_session_id INTEGER, + ecu_status VARCHAR(20), + last_seen TIMESTAMP, + enabled BOOLEAN DEFAULT TRUE + ) + """) + ) + conn.execute( + text(f""" + CREATE TABLE drive_sessions ( + id {pk}, + vin VARCHAR(17) NOT NULL, + device_id VARCHAR(20) NOT NULL, + started_at TIMESTAMP NOT NULL, + ended_at TIMESTAMP, + duration_seconds INTEGER, + external_session_id VARCHAR(64) + ) + """) + ) + conn.execute( + text(f""" + CREATE TABLE vehicle_telemetry ( + id {pk}, + vin VARCHAR(17) NOT NULL, + device_id VARCHAR(20) NOT NULL, + param_key VARCHAR(100) NOT NULL, + value FLOAT NOT NULL, + timestamp TIMESTAMP NOT NULL + ) + """) + ) + + +def _add_device(conn, device_id: str, current_session_id: int | None = None) -> None: + conn.execute( + text( + "INSERT INTO livelink_devices (device_id, vin, current_session_id) VALUES (:d, :v, :c)" + ), + {"d": device_id, "v": f"VIN{device_id:0>14}", "c": current_session_id}, + ) + + +def _add_session( + conn, + device_id: str, + session_id: int, + started_at: datetime, + ended_at: datetime | None = None, + external_session_id: str | None = None, +) -> None: + conn.execute( + text( + "INSERT INTO drive_sessions " + "(id, vin, device_id, started_at, ended_at, external_session_id) " + "VALUES (:i, :v, :d, :s, :e, :x)" + ), + { + "i": session_id, + "v": f"VIN{device_id:0>14}", + "d": device_id, + "s": started_at, + "e": ended_at, + "x": external_session_id, + }, + ) + + +def _add_telemetry(conn, device_id: str, at: datetime, key: str = "SPEED", value: float = 40.0): + conn.execute( + text( + "INSERT INTO vehicle_telemetry (vin, device_id, param_key, value, timestamp) " + "VALUES (:v, :d, :k, :val, :t)" + ), + {"v": f"VIN{device_id:0>14}", "d": device_id, "k": key, "val": value, "t": at}, + ) + + +def _open_sessions(engine, device_id: str) -> list[int]: + with engine.connect() as conn: + return [ + r[0] + for r in conn.execute( + text( + "SELECT id FROM drive_sessions WHERE device_id = :d " + "AND ended_at IS NULL ORDER BY id" + ), + {"d": device_id}, + ) + ] + + +def _session_row(engine, session_id: int) -> dict: + with engine.connect() as conn: + row = conn.execute( + text("SELECT id, started_at, ended_at FROM drive_sessions WHERE id = :i"), + {"i": session_id}, + ).one() + return {"id": row[0], "started_at": row[1], "ended_at": row[2]} + + +def _pointer(engine, device_id: str) -> int | None: + with engine.connect() as conn: + return conn.execute( + text("SELECT current_session_id FROM livelink_devices WHERE device_id = :d"), + {"d": device_id}, + ).scalar_one() + + +def _as_dt(value) -> datetime | None: + """SQLite hands back strings for TIMESTAMP columns declared in raw DDL.""" + if value is None or isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value)) + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("engine_for_migration", ["sqlite", "pg"], indirect=True) +class TestSchema: + def test_every_expected_column_is_added(self, engine_for_migration): + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + + insp = inspect(engine) + before = { + (t, c["name"]) + for t in ("livelink_devices", "drive_sessions") + for c in insp.get_columns(t) + } + missing_before = [(t, c) for t, c, _n in EXPECTED_COLUMNS if (t, c) not in before] + assert missing_before == [(t, c) for t, c, _n in EXPECTED_COLUMNS], ( + "the pre-098 fixture already has some of these columns, so the " + f"assertions below would pass without the migration: {missing_before}" + ) + + _load("098_session_boundaries").upgrade(engine) + + insp = inspect(engine) + actual = { + t: {c["name"]: c for c in insp.get_columns(t)} + for t in ("livelink_devices", "drive_sessions") + } + for table, column, nullable in EXPECTED_COLUMNS: + assert column in actual[table], f"{table}.{column} was not added" + assert bool(actual[table][column]["nullable"]) is nullable, ( + f"{table}.{column} nullability is wrong" + ) + + def test_boundary_version_defaults_existing_rows_to_zero(self, engine_for_migration): + """Zero means "cut by the old rule", and getting it backwards is unrecoverable. + + Every pre-098 session was bounded by contact, not movement. Stamping + them `1` would claim they follow semantics they do not, and any later + pass over history -- whose whole job is finding sessions cut the old + way -- would skip every one of them, forever. + """ + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=None) + _add_session(conn, "dev1", 1, BASE, BASE + timedelta(minutes=20)) + + _load("098_session_boundaries").upgrade(engine) + + with engine.connect() as conn: + version = conn.execute( + text("SELECT boundary_algorithm_version FROM drive_sessions WHERE id = 1") + ).scalar_one() + gap = conn.execute( + text("SELECT effective_gap_minutes FROM drive_sessions WHERE id = 1") + ).scalar_one() + assert version == 0 + assert gap is None, "NULL means 'the old contact timeout applied', not 'zero minutes'" + + def test_the_movement_index_exists(self, engine_for_migration): + """The timeout query scans on `last_movement_at`; unindexed it is a table scan.""" + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + _load("098_session_boundaries").upgrade(engine) + + names = {i["name"] for i in inspect(engine).get_indexes("livelink_devices")} + assert "ix_livelink_devices_last_movement_at" in names + + def test_a_second_open_session_is_rejected(self, engine_for_migration): + """The index is the point of the migration, so assert it BITES. + + Asserting the index merely exists would pass against a non-unique index, + a full (non-partial) unique index that rejects legitimate closed + sessions, or one built on the wrong column. + """ + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=1) + _add_session(conn, "dev1", 1, BASE) + _load("098_session_boundaries").upgrade(engine) + + with pytest.raises(Exception) as excinfo: + with engine.begin() as conn: + _add_session(conn, "dev1", 2, BASE + timedelta(minutes=5)) + assert "unique" in str(excinfo.value).lower() + + def test_closed_sessions_are_not_constrained(self, engine_for_migration): + """The index must be PARTIAL. A plain unique index on device_id would + allow one session per device ever, which is not a constraint anyone + wants and would break on the second drive.""" + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=None) + _load("098_session_boundaries").upgrade(engine) + + with engine.begin() as conn: + _add_session(conn, "dev1", 1, BASE, BASE + timedelta(minutes=10)) + _add_session(conn, "dev1", 2, BASE + timedelta(hours=1), BASE + timedelta(hours=2)) + _add_session(conn, "dev1", 3, BASE + timedelta(hours=3)) # one open is fine + assert _open_sessions(engine, "dev1") == [3] + + +# --------------------------------------------------------------------------- +# Preflight: the open-session inventory +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("engine_for_migration", ["sqlite", "pg"], indirect=True) +class TestOpenSessionPreflight: + def test_the_pointed_at_session_is_retained_even_when_older(self, engine_for_migration): + """The case a "close the older duplicates" rule gets backwards. + + In the race the pointer can land on the OLDER row -- MQTT created first + and won the pointer, HTTPS created second and was orphaned. Closing by + timestamp closes the session the device is actively writing to and keeps + the orphan, which is worse than doing nothing. + """ + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=1) + _add_session(conn, "dev1", 1, BASE) # older, pointed at + _add_session(conn, "dev1", 2, BASE + timedelta(minutes=3)) # newer orphan + _add_telemetry(conn, "dev1", BASE + timedelta(minutes=1)) + + _load("098_session_boundaries").upgrade(engine) + + assert _open_sessions(engine, "dev1") == [1], "the pointed-at session must survive" + assert _pointer(engine, "dev1") == 1 + assert _session_row(engine, 2)["ended_at"] is not None + + def test_without_a_pointer_the_newest_is_retained(self, engine_for_migration): + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=None) + _add_session(conn, "dev1", 1, BASE) + _add_session(conn, "dev1", 2, BASE + timedelta(minutes=30)) + + _load("098_session_boundaries").upgrade(engine) + + assert _open_sessions(engine, "dev1") == [2] + assert _pointer(engine, "dev1") == 2, "the pointer is repaired to the retained row" + + def test_a_stale_pointer_to_a_closed_session_falls_back_to_newest(self, engine_for_migration): + """`current_session_id` naming an already-CLOSED row is not a valid retention.""" + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=1) + _add_session(conn, "dev1", 1, BASE, BASE + timedelta(minutes=5)) # closed + _add_session(conn, "dev1", 2, BASE + timedelta(minutes=10)) + _add_session(conn, "dev1", 3, BASE + timedelta(minutes=40)) + + _load("098_session_boundaries").upgrade(engine) + + assert _open_sessions(engine, "dev1") == [3] + assert _pointer(engine, "dev1") == 3 + + def test_the_singleton_orphan_is_closed(self, engine_for_migration): + """One open row with a NULL pointer violates nothing today. + + `unlink_device` clears the pointer without closing the session, so this + shape exists in the wild. A duplicate scan does not see it -- there is + no duplicate -- and once the index exists it silently rejects every + future session for that device. This is the case a "find the dupes" + preflight leaves behind. + """ + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=None) + _add_session(conn, "dev1", 1, BASE) + _add_telemetry(conn, "dev1", BASE + timedelta(minutes=12)) + + _load("098_session_boundaries").upgrade(engine) + + assert _open_sessions(engine, "dev1") == [1], ( + "a lone open session is legal; only its POINTER was missing, and " + "repairing the pointer is the fix, not closing the session" + ) + assert _pointer(engine, "dev1") == 1 + + def test_an_orphan_is_closed_at_its_own_last_telemetry(self, engine_for_migration): + """Not at 'now', which would invent hours of drive on a session from March.""" + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + last_sample = BASE + timedelta(minutes=7) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=2) + _add_session(conn, "dev1", 1, BASE) # orphan + _add_session(conn, "dev1", 2, BASE + timedelta(hours=5)) # retained + _add_telemetry(conn, "dev1", BASE + timedelta(minutes=2)) + _add_telemetry(conn, "dev1", last_sample) + + _load("098_session_boundaries").upgrade(engine) + + assert _as_dt(_session_row(engine, 1)["ended_at"]) == last_sample + + def test_an_orphan_close_cannot_overlap_the_retained_session(self, engine_for_migration): + """Clamping, stated as its own case because the unclamped version looks fine. + + The orphan's last telemetry can be NEWER than the retained session's + start -- the two windows genuinely overlap in the race. Closing the + orphan at that sample leaves two sessions both claiming the same + telemetry, and every aggregate is a window scan, so both report the same + distance and the vehicle appears to have driven it twice. + """ + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + retained_start = BASE + timedelta(minutes=10) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=2) + _add_session(conn, "dev1", 1, BASE) + _add_session(conn, "dev1", 2, retained_start) + _add_telemetry(conn, "dev1", BASE + timedelta(minutes=45)) # well past retained start + + _load("098_session_boundaries").upgrade(engine) + + ended = _as_dt(_session_row(engine, 1)["ended_at"]) + assert ended is not None + assert ended <= retained_start, f"orphan closed at {ended}, overlapping {retained_start}" + + def test_an_orphan_with_no_telemetry_closes_at_its_own_start(self, engine_for_migration): + """A zero-length session is honest; a session ending at `now` is not.""" + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=2) + _add_session(conn, "dev1", 1, BASE) + _add_session(conn, "dev1", 2, BASE + timedelta(hours=2)) + + _load("098_session_boundaries").upgrade(engine) + + row = _session_row(engine, 1) + assert _as_dt(row["ended_at"]) == _as_dt(row["started_at"]) + + def test_devices_are_reconciled_independently(self, engine_for_migration): + """Guard against a preflight that keeps one open session GLOBALLY.""" + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=1) + _add_device(conn, "dev2", current_session_id=3) + _add_session(conn, "dev1", 1, BASE) + _add_session(conn, "dev1", 2, BASE + timedelta(minutes=1)) + _add_session(conn, "dev2", 3, BASE) + _add_session(conn, "dev2", 4, BASE + timedelta(minutes=1)) + + _load("098_session_boundaries").upgrade(engine) + + assert _open_sessions(engine, "dev1") == [1] + assert _open_sessions(engine, "dev2") == [3] + + def test_a_session_whose_device_row_is_gone_is_closed(self, engine_for_migration): + """`drive_sessions.device_id` carries no FK, and device deletes retain + history by design, so an open session with no device row is reachable. + It has no pointer to consult and would block nothing -- but it also can + never be closed by any live path again, so the preflight closes it.""" + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_session(conn, "ghost", 1, BASE) + + _load("098_session_boundaries").upgrade(engine) + + assert _session_row(engine, 1)["ended_at"] is not None + + +# --------------------------------------------------------------------------- +# Re-entrancy +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("engine_for_migration", ["sqlite", "pg"], indirect=True) +def test_running_twice_is_safe(engine_for_migration): + """The runner stamps in a separate transaction from the upgrade, so a crash + in between re-runs this migration against its own output.""" + _dialect, engine, _url = engine_for_migration + _make_pre_098_schema(engine) + with engine.begin() as conn: + _add_device(conn, "dev1", current_session_id=1) + _add_session(conn, "dev1", 1, BASE) + _add_session(conn, "dev1", 2, BASE + timedelta(minutes=2)) + + mod = _load("098_session_boundaries") + mod.upgrade(engine) + first = _open_sessions(engine, "dev1") + mod.upgrade(engine) + + assert _open_sessions(engine, "dev1") == first + insp = inspect(engine) + for table, column, _n in EXPECTED_COLUMNS: + assert column in {c["name"] for c in insp.get_columns(table)} + + +@pytest.mark.parametrize("engine_for_migration", ["sqlite", "pg"], indirect=True) +def test_it_is_not_fatal(engine_for_migration): + """An instance that cannot take the index is still usable; a crash-loop is not. + + The runner log-and-continues for non-FATAL migrations, which is the right + trade here: without the index the race stays possible, and with FATAL=True a + database that cannot take it never starts again. + """ + _dialect, _engine, _url = engine_for_migration + assert getattr(_load("098_session_boundaries"), "FATAL", False) is False diff --git a/backend/tests/migrations/test_099_add_vehicle_fuel_filter.py b/backend/tests/migrations/test_099_add_vehicle_fuel_filter.py new file mode 100644 index 00000000..32a8af15 --- /dev/null +++ b/backend/tests/migrations/test_099_add_vehicle_fuel_filter.py @@ -0,0 +1,82 @@ +"""Tests for migration 099 - vehicles.fuel_filter_part_number. + +FATAL migration: the Vehicle ORM maps this column, so every vehicle SELECT +includes it and a silent skip would 500 all vehicle reads. Parameterized over +SQLite *and* PostgreSQL via the ``engine_for_migration`` fixture (PG runs skip +when ``TEST_DATABASE_URL`` is unset). +""" + +import importlib.util +from pathlib import Path + +from sqlalchemy import inspect, text + +import app.migrations as _m + + +def _load(name): + path = Path(_m.__file__).parent / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _make_vehicles_table(engine): + with engine.begin() as conn: + conn.execute( + text( + """ + CREATE TABLE vehicles ( + vin VARCHAR(17) PRIMARY KEY, + nickname VARCHAR(80) + ) + """ + ) + ) + + +def test_099_adds_the_fuel_filter_column(engine_for_migration): + _dialect, engine, _url = engine_for_migration + _make_vehicles_table(engine) + + _load("099_add_vehicle_fuel_filter").upgrade(engine) + + cols = {c["name"] for c in inspect(engine).get_columns("vehicles")} + assert "fuel_filter_part_number" in cols + + +def test_099_is_idempotent(engine_for_migration): + """Second run is a no-op - no raise, no duplicate column.""" + _dialect, engine, _url = engine_for_migration + _make_vehicles_table(engine) + mod = _load("099_add_vehicle_fuel_filter") + mod.upgrade(engine) + mod.upgrade(engine) + cols = [c["name"] for c in inspect(engine).get_columns("vehicles")] + assert cols.count("fuel_filter_part_number") == 1 + + +def test_099_missing_table_skips(engine_for_migration): + """Fresh DB without a vehicles table must skip, not raise.""" + _dialect, engine, _url = engine_for_migration + _load("099_add_vehicle_fuel_filter").upgrade(engine) + + +def test_099_leaves_existing_rows_null(engine_for_migration): + """An added spec column must not invent a value for vehicles that have none. + + A part number is a fact about a specific vehicle. Backfilling any default + would assert a filter this instance has never been told about, and the card + renders a spec only when it holds a value. + """ + _dialect, engine, _url = engine_for_migration + _make_vehicles_table(engine) + with engine.begin() as conn: + conn.execute(text("INSERT INTO vehicles (vin, nickname) VALUES ('X', 'Ram')")) + + _load("099_add_vehicle_fuel_filter").upgrade(engine) + + with engine.connect() as conn: + value = conn.execute(text("SELECT fuel_filter_part_number FROM vehicles")).scalar() + assert value is None diff --git a/backend/tests/services/test_location_service.py b/backend/tests/services/test_location_service.py index 0124e68f..ec69217c 100644 --- a/backend/tests/services/test_location_service.py +++ b/backend/tests/services/test_location_service.py @@ -20,8 +20,15 @@ FIXED_TS = datetime(2026, 7, 17, 12, 0, 0) # naive UTC -async def _make_vehicle(db_session: AsyncSession) -> str: - """Create a minimal user + vehicle, return the (unique) vin.""" +async def _make_vehicle(db_session: AsyncSession) -> tuple[str, str]: + """Create a minimal user + vehicle, return its unique (vin, device_id). + + The device id used to be the literal `device_id` in every test in this file. + The suite shares one database with no per-test rollback, so two tests that + each seeded an OPEN session for `dev1` left two open sessions on one + device -- which is exactly what `uq_drive_sessions_open_per_device` now + forbids. Both tests were legitimate; the shared identifier was not. + """ n = next(_SEQ) user = User( username=f"loc_svc_user_{n}", @@ -42,7 +49,7 @@ async def _make_vehicle(db_session: AsyncSession) -> str: ) db_session.add(vehicle) await db_session.flush() - return vin + return vin, f"locsvcdev{n:04d}" @pytest.mark.asyncio @@ -52,17 +59,17 @@ async def test_record_point_inserts_and_dedups_on_vin_timestamp_source( """First record_point inserts one row and returns True; a duplicate (vin, timestamp, 'torque') returns False and does not create a second row. """ - vin = await _make_vehicle(db_session) + vin, device_id = await _make_vehicle(db_session) service = LocationService(db_session) first = await service.record_point( - vin, "dev1", None, FIXED_TS, Decimal("47.620000"), Decimal("-122.350000") + vin, device_id, None, FIXED_TS, Decimal("47.620000"), Decimal("-122.350000") ) await db_session.commit() assert first is True second = await service.record_point( - vin, "dev1", None, FIXED_TS, Decimal("47.620000"), Decimal("-122.350000") + vin, device_id, None, FIXED_TS, Decimal("47.620000"), Decimal("-122.350000") ) await db_session.commit() assert second is False @@ -83,12 +90,12 @@ async def test_get_trips_only_sessions_with_points_correct_counts_newest_first( """get_trips excludes sessions with zero location points, reports the right point_count per session, and orders newest-first by started_at. """ - vin = await _make_vehicle(db_session) + vin, device_id = await _make_vehicle(db_session) service = LocationService(db_session) session_old = DriveSession( vin=vin, - device_id="dev1", + device_id=device_id, started_at=datetime(2026, 7, 10, 8, 0, 0), ended_at=datetime(2026, 7, 10, 8, 30, 0), duration_seconds=1800, @@ -96,14 +103,14 @@ async def test_get_trips_only_sessions_with_points_correct_counts_newest_first( ) session_new = DriveSession( vin=vin, - device_id="dev1", + device_id=device_id, started_at=datetime(2026, 7, 15, 8, 0, 0), ended_at=datetime(2026, 7, 15, 8, 30, 0), duration_seconds=1800, distance_km=20.0, ) session_empty = DriveSession( - vin=vin, device_id="dev1", started_at=datetime(2026, 7, 12, 8, 0, 0) + vin=vin, device_id=device_id, started_at=datetime(2026, 7, 12, 8, 0, 0) ) db_session.add_all([session_old, session_new, session_empty]) await db_session.flush() @@ -111,7 +118,7 @@ async def test_get_trips_only_sessions_with_points_correct_counts_newest_first( # session_old: 2 points await service.record_point( vin, - "dev1", + device_id, session_old.id, datetime(2026, 7, 10, 8, 0, 0), Decimal("47.60"), @@ -119,7 +126,7 @@ async def test_get_trips_only_sessions_with_points_correct_counts_newest_first( ) await service.record_point( vin, - "dev1", + device_id, session_old.id, datetime(2026, 7, 10, 8, 5, 0), Decimal("47.61"), @@ -128,7 +135,7 @@ async def test_get_trips_only_sessions_with_points_correct_counts_newest_first( # session_new: 3 points await service.record_point( vin, - "dev1", + device_id, session_new.id, datetime(2026, 7, 15, 8, 0, 0), Decimal("47.60"), @@ -136,7 +143,7 @@ async def test_get_trips_only_sessions_with_points_correct_counts_newest_first( ) await service.record_point( vin, - "dev1", + device_id, session_new.id, datetime(2026, 7, 15, 8, 5, 0), Decimal("47.61"), @@ -144,7 +151,7 @@ async def test_get_trips_only_sessions_with_points_correct_counts_newest_first( ) await service.record_point( vin, - "dev1", + device_id, session_new.id, datetime(2026, 7, 15, 8, 10, 0), Decimal("47.62"), @@ -171,9 +178,9 @@ async def test_get_trip_points_ordered_by_timestamp_scoped_to_vin( """get_trip_points returns a session's points ordered by timestamp ascending, regardless of insertion order. """ - vin = await _make_vehicle(db_session) + vin, device_id = await _make_vehicle(db_session) service = LocationService(db_session) - session = DriveSession(vin=vin, device_id="dev1", started_at=datetime(2026, 7, 16, 9, 0, 0)) + session = DriveSession(vin=vin, device_id=device_id, started_at=datetime(2026, 7, 16, 9, 0, 0)) db_session.add(session) await db_session.flush() @@ -181,9 +188,9 @@ async def test_get_trip_points_ordered_by_timestamp_scoped_to_vin( t2 = datetime(2026, 7, 16, 9, 5, 0) t3 = datetime(2026, 7, 16, 9, 10, 0) # insert out of chronological order - await service.record_point(vin, "dev1", session.id, t3, Decimal("47.62"), Decimal("-122.32")) - await service.record_point(vin, "dev1", session.id, t1, Decimal("47.60"), Decimal("-122.30")) - await service.record_point(vin, "dev1", session.id, t2, Decimal("47.61"), Decimal("-122.31")) + await service.record_point(vin, device_id, session.id, t3, Decimal("47.62"), Decimal("-122.32")) + await service.record_point(vin, device_id, session.id, t1, Decimal("47.60"), Decimal("-122.30")) + await service.record_point(vin, device_id, session.id, t2, Decimal("47.61"), Decimal("-122.31")) await db_session.commit() points = await service.get_trip_points(vin, session.id) @@ -196,15 +203,15 @@ async def test_get_last_location_returns_the_newest_point(db_session: AsyncSessi """get_last_location returns the most recent point for the vin, and None when there are no points at all. """ - vin = await _make_vehicle(db_session) + vin, device_id = await _make_vehicle(db_session) service = LocationService(db_session) assert await service.get_last_location(vin) is None t1 = datetime(2026, 7, 16, 9, 0, 0) t2 = datetime(2026, 7, 16, 10, 0, 0) - await service.record_point(vin, "dev1", None, t1, Decimal("47.60"), Decimal("-122.30")) - await service.record_point(vin, "dev1", None, t2, Decimal("47.61"), Decimal("-122.31")) + await service.record_point(vin, device_id, None, t1, Decimal("47.60"), Decimal("-122.30")) + await service.record_point(vin, device_id, None, t2, Decimal("47.61"), Decimal("-122.31")) await db_session.commit() last = await service.get_last_location(vin) diff --git a/backend/tests/unit/routes/test_orm_attribute_guard.py b/backend/tests/unit/routes/test_orm_attribute_guard.py new file mode 100644 index 00000000..7f58c615 --- /dev/null +++ b/backend/tests/unit/routes/test_orm_attribute_guard.py @@ -0,0 +1,277 @@ +"""CSV import and export may only name attributes their models actually have. + +The standing half of the v3.3.0 CSV fix. Three importers and two exporters were +naming attributes that do not exist, and the fixed cases are covered by +round-trip tests elsewhere; this is the guard that catches the next one. + +It is here because the bug class is invisible at every other layer: + +- **Export** reads `record.premium` on a model whose column is + `premium_amount`. Python raises `AttributeError` at request time, so the + route 500s for any vehicle that has such a record and passes for every + vehicle that does not. +- **Import** constructs `WarrantyRecord(coverage=...)`. SQLAlchemy's + declarative constructor raises `TypeError`, which the enclosing + `except Exception` turns into `add_error(row, "Invalid record data")`. The + endpoint then returns **HTTP 200** with an error per row, so the response + blames the user's CSV for an application bug. Nobody reports that. + +Tax was the case both earlier hand-written enumerations missed, and it is the +one that shows why a standing guard beats a list: tax EXPORT was always fine, +so enumerating outward from the export bug could not reach it. Four nonexistent +kwargs meant no tax record had ever imported. + +`test_the_enumerators_find_what_they_claim_to` is the guard on the guard. Both +walkers infer bindings from code shape, and a refactor that changed the shape +would make them return nothing and every assertion here pass vacuously. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest +from sqlalchemy import inspect as sa_inspect +from sqlalchemy.orm import RelationshipProperty + +import app.models as models + +EXPORT = Path("app/routes/export.py") +IMPORT = Path("app/routes/import_data.py") + + +def _mapped(name: str) -> type | None: + """The mapped class exported under this name, or None.""" + cls = getattr(models, name, None) + if cls is None: + return None + try: + sa_inspect(cls) + except Exception: + return None + return cls + + +def _attrs(cls: type) -> set[str]: + """Every attribute the mapper knows: columns, relationships, synonyms. + + This is exactly the set SQLAlchemy's declarative constructor accepts, so a + kwarg outside it is the `TypeError` the importer swallows. + """ + return set(sa_inspect(cls).attrs.keys()) + + +def _relationship_target(cls: type, attr: str) -> type | None: + prop = sa_inspect(cls).attrs.get(attr) + if isinstance(prop, RelationshipProperty): + return prop.mapper.class_ + return None + + +def _functions(tree: ast.AST): + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + yield node + + +def _select_bindings(fn: ast.AST) -> dict[str, type]: + """Variables holding rows of a model: `x = (await db.execute(select(M))).scalars().all()`. + + These are QUERY variables, not row variables. Reads on them (`result.scalars()`) + are not model attribute reads, which is why the checker below looks only at + loop targets. + """ + bound: dict[str, type] = {} + for node in ast.walk(fn): + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + target = node.targets[0] + if not isinstance(target, ast.Name): + continue + found: type | None = None + for call in ast.walk(node.value): + if ( + isinstance(call, ast.Call) + and isinstance(call.func, ast.Name) + and call.func.id == "select" + ): + for arg in call.args: + name = None + if isinstance(arg, ast.Name): + name = arg.id + elif isinstance(arg, ast.Attribute) and isinstance(arg.value, ast.Name): + name = arg.value.id + if name and (cls := _mapped(name)): + found = cls + if found is not None: + bound[target.id] = found + continue + for sub in ast.walk(node.value): + if isinstance(sub, ast.Name) and sub.id in bound: + bound[target.id] = bound[sub.id] + break + return bound + + +def _iterations(fn: ast.AST): + """Every row-producing scope: `for` statements AND comprehensions. + + Comprehensions matter as much as statements. `export_vehicle_json` selects + six models and contains no `for` STATEMENT at all, so a walker handling + only `ast.For` would skip the largest handler in the file and report a + clean run. + + Each is yielded with its OWN body, because bindings do not survive between + them: `export_vehicle_json` reuses the name `r` for fuel, DEF and hours + rows in three sibling comprehensions, and a per-function binding table + resolves all of them to whichever came last. + """ + for node in ast.walk(fn): + if isinstance(node, ast.For): + yield node.target, node.iter, node.body + elif isinstance(node, ast.ListComp | ast.SetComp | ast.GeneratorExp | ast.DictComp): + elts = [node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt] + for gen in node.generators: + yield gen.target, gen.iter, elts + list(gen.ifs) + + +def export_attribute_reads() -> list[tuple[str, type, str]]: + """Every `.` an export handler reads, with the model it is on. + + A nested loop over a relationship (`for item in visit.line_items`) is bound + to the RELATED model rather than the outer one. An earlier version of this + walker treated it as the outer model and reported `export_service_records` + as broken; the count went 3 to 2 when that was fixed, which is why the + resolution is by relationship rather than by skipping nested loops. + """ + tree = ast.parse(EXPORT.read_text()) + reads: list[tuple[str, type, str]] = [] + + for fn in _functions(tree): + queries = _select_bindings(fn) + for target, iterable, body in _iterations(fn): + if not isinstance(target, ast.Name): + continue + row_model: type | None = None + if isinstance(iterable, ast.Name): + row_model = queries.get(iterable.id) + elif isinstance(iterable, ast.Attribute) and isinstance(iterable.value, ast.Name): + outer = queries.get(iterable.value.id) + # `for item in visit.line_items`: the outer name may itself be a + # row variable rather than a query variable, so fall back to the + # models any loop in this function binds. + if outer is None: + for t2, it2, _b2 in _iterations(fn): + if ( + isinstance(t2, ast.Name) + and t2.id == iterable.value.id + and isinstance(it2, ast.Name) + ): + outer = queries.get(it2.id) + break + if outer is not None: + row_model = _relationship_target(outer, iterable.attr) + if row_model is None: + continue + for stmt in body: + for node in ast.walk(stmt): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == target.id + ): + reads.append((fn.name, row_model, node.attr)) + return reads + + +def import_constructions() -> list[tuple[int, type, list[str]]]: + """Every `Model(...)` built by the importer, with its keyword names.""" + tree = ast.parse(IMPORT.read_text()) + built: list[tuple[int, type, list[str]]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + cls = _mapped(node.func.id) + if cls is None: + continue + built.append((node.lineno, cls, [kw.arg for kw in node.keywords if kw.arg])) + return built + + +def test_every_import_construction_uses_real_attributes(): + offenders = [ + f"{IMPORT.name}:{line} {cls.__name__} {sorted(set(kwargs) - _attrs(cls))}" + for line, cls, kwargs in import_constructions() + if set(kwargs) - _attrs(cls) + ] + assert offenders == [], ( + "These constructions name attributes their model does not have. " + "SQLAlchemy raises TypeError, the importer's `except Exception` turns " + "it into 'Invalid record data', and the endpoint answers 200 blaming " + f"the user's file: {offenders}" + ) + + +def test_every_export_read_is_a_real_attribute(): + #: Read on a model instance but not mapped. `metadata` is SQLAlchemy's own + #: and `_sa_instance_state` is internal; neither is a column, and neither is + #: the defect this guards. + # `hasattr`, not the mapper set. The export defect is an AttributeError at + # request time, so a hybrid property or a plain `@property` on the model is + # a perfectly good read even though it is not a mapped column. + # `ServiceVisit.calculated_total_cost` is exactly that, and a mapper-only + # check calls it broken. + offenders = sorted( + { + f"{fn}: {cls.__name__}.{attr}" + for fn, cls, attr in export_attribute_reads() + if not hasattr(cls, attr) + } + ) + assert offenders == [], ( + "These export handlers read attributes their model does not have, " + f"which is a 500 for any vehicle that has such a record: {offenders}" + ) + + +class TestTheEnumeratorsFindWhatTheyClaimTo: + """Both walkers infer from code shape, so both can silently find nothing.""" + + def test_the_export_walker_sees_a_known_read(self): + reads = {(fn, cls.__name__, attr) for fn, cls, attr in export_attribute_reads()} + assert ("export_warranties_csv", "WarrantyRecord", "policy_number") in reads + + def test_the_export_walker_sees_comprehensions_too(self): + """`export_vehicle_json` has no `for` STATEMENT. + + It selects six models and reads them all inside list comprehensions, so + a walker handling only `ast.For` would skip the biggest handler in the + file while reporting a clean run. + """ + names = {fn for fn, _c, _a in export_attribute_reads()} + assert "export_vehicle_json" in names + + def test_the_export_walker_resolves_a_nested_relationship(self): + """`for item in visit.line_items` is a ServiceLineItem, not a ServiceVisit. + + Binding it to the outer model produced a false positive that read as a + third broken handler. + """ + reads = {(cls.__name__, attr) for _f, cls, attr in export_attribute_reads()} + assert any(cls == "ServiceLineItem" for cls, _ in reads), reads + + def test_the_import_walker_sees_a_known_construction(self): + built = { + (cls.__name__, tuple(sorted(kwargs))) for _l, cls, kwargs in import_constructions() + } + assert any(cls == "WarrantyRecord" for cls, _ in built), built + + @pytest.mark.parametrize("cls_name", ["WarrantyRecord", "InsurancePolicy", "TaxRecord"]) + def test_the_three_repaired_importers_are_still_walked(self, cls_name: str): + """Named individually, because these are the three that were broken. + + A refactor that moved one of them out of `import_data.py` would take it + out of this guard's reach, and the guard would keep passing. + """ + assert any(cls.__name__ == cls_name for _l, cls, _k in import_constructions()) diff --git a/backend/tests/unit/services/test_fuel_hours_economy.py b/backend/tests/unit/services/test_fuel_hours_economy.py index bcbc5a37..88783ebe 100644 --- a/backend/tests/unit/services/test_fuel_hours_economy.py +++ b/backend/tests/unit/services/test_fuel_hours_economy.py @@ -257,7 +257,7 @@ async def test_zero_liters_interval_excluded_from_l_per_hr_average_not_cost( in a phantom 0.00; average_cost_per_hr averages BOTH intervals (1.75), since cost/hr is never suppressed for want of liters. """ - vin = "HOURSAVGZERO000001" + vin = "HOURSAVGZERO00001" db_session.add(Vehicle(vin=vin, nickname="Avg Zero Liters", vehicle_type="Car")) await db_session.flush() db_session.add_all( @@ -303,7 +303,7 @@ async def test_all_zero_liters_yields_none_average_l_per_hr_but_averages_cost( """When EVERY scored interval is zero-liters, average_l_per_hr must be None (never a ZeroDivisionError, never a phantom 0.00) while average_cost_per_hr still averages the valid cost/hr figures.""" - vin = "HOURSAVGZERO000002" + vin = "HOURSAVGZERO00002" db_session.add(Vehicle(vin=vin, nickname="Avg All Zero", vehicle_type="Car")) await db_session.flush() db_session.add_all( diff --git a/backend/tests/unit/services/test_movement_unreadable_devices.py b/backend/tests/unit/services/test_movement_unreadable_devices.py new file mode 100644 index 00000000..a700d929 --- /dev/null +++ b/backend/tests/unit/services/test_movement_unreadable_devices.py @@ -0,0 +1,204 @@ +"""Which devices genuinely cannot have their movement read. + +The rule, and why asking `last_movement_at IS NULL` alone named entire fleets on +the first boot after migration 098, are argued on +`LiveLinkService.movement_unreadable_device_ids`. + +Every test here seeds the state that makes it meaningful: the method's default +answer is "not flagged", so an assertion of absence proves nothing unless the +fixture is one that SHOULD have been flagged but for the single property under +test. +""" + +from datetime import timedelta + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.livelink_device import LiveLinkDevice +from app.models.vehicle_telemetry import VehicleTelemetry +from app.services.livelink_service import LiveLinkService +from app.utils.datetime_utils import utc_now + + +@pytest_asyncio.fixture +async def make_vehicle(make_livelink_vehicle): + async def _factory(suffix: str) -> tuple[str, LiveLinkDevice]: + return await make_livelink_vehicle("munr", suffix) + + return _factory + + +@pytest_asyncio.fixture +async def seed(db_session: AsyncSession): + """Async factory: (vin, device_id, [(param_key, age_days)]) -> None.""" + + async def _factory(vin: str, device_id: str, samples: list[tuple[str, float]]) -> None: + now = utc_now() + for param_key, age_days in samples: + db_session.add( + VehicleTelemetry( + vin=vin, + device_id=device_id, + param_key=param_key, + value=1.0, + timestamp=now - timedelta(days=age_days), + received_at=now - timedelta(days=age_days), + ) + ) + await db_session.flush() + + return _factory + + +@pytest.mark.asyncio +class TestMovementUnreadableDeviceIds: + async def test_engine_telemetry_without_movement_is_flagged( + self, db_session, make_vehicle, seed + ): + """The actionable case: plainly running, and nothing here can read it.""" + vin, device = await make_vehicle("1") + await seed(vin, device.device_id, [("0C-ENGINERPM", 1), ("CUSTOM_ROAD_SPEED", 1)]) + + flagged = await LiveLinkService(db_session).movement_unreadable_device_ids([device]) + + assert device.device_id in flagged + + async def test_legible_rpm_does_not_make_a_device_readable( + self, db_session, make_vehicle, seed + ): + """RPM is a movement signal and still does not answer this question. + + An engine turning with the vehicle stationary is a remote start, a + warm-up or a driveway idle, so RPM opens a PENDING drive and never + confirms one. A device whose RPM is perfectly legible but whose speed + arrives under an unrecognised name records no sessions at all, which is + exactly the cohort this names. Counting RPM as readable would hide it. + """ + vin, device = await make_vehicle("10") + await seed(vin, device.device_id, [("0C-ENGINERPM", 1), ("CUSTOM_ROAD_SPEED", 1)]) + + flagged = await LiveLinkService(db_session).movement_unreadable_device_ids([device]) + + assert device.device_id in flagged + + async def test_a_parked_heartbeat_alone_is_not_flagged(self, db_session, make_vehicle, seed): + """The day-one false alarm, and the reason this is not `last_movement_at IS NULL`. + + Right after the upgrade every device has no movement on record. One that + has published nothing but its battery heartbeat since is a parked + vehicle behaving correctly, and naming it would fire the warning for an + entire fleet on first boot. + """ + vin, device = await make_vehicle("2") + await seed(vin, device.device_id, [("BATTERY_VOLTAGE", 0.5), ("BATTERY_VOLTAGE", 2)]) + + flagged = await LiveLinkService(db_session).movement_unreadable_device_ids([device]) + + assert device.device_id not in flagged + + async def test_a_readable_device_is_not_flagged_before_it_has_moved( + self, db_session, make_vehicle, seed + ): + """The day migration 098 runs, and the bug that survived the first fix. + + `last_movement_at` is a column 098 CREATES, so it is null for every + device that exists until fresh telemetry arrives. Pairing that against + seven days of telemetry HISTORY compares two different time bases: the + history is almost entirely older than the column. Every device driven in + the last week but not since the upgrade came out flagged, which on a + real database was the entire fleet. + + So the question is asked without reference to time at all. This device + publishes `0D-VEHICLESPEED`, which this codebase reads; whether it has + moved YET is a different question and not the one the notice answers. + """ + vin, device = await make_vehicle("3") + assert device.last_movement_at is None, "as 098 leaves every existing device" + await seed( + vin, + device.device_id, + [("0C-ENGINERPM", 1), ("0D-VEHICLESPEED", 1), ("05-ENGINECOOLANTTEMP", 1)], + ) + + flagged = await LiveLinkService(db_session).movement_unreadable_device_ids([device]) + + assert device.device_id not in flagged + + async def test_an_unprefixed_odometer_alone_counts_as_readable( + self, db_session, make_vehicle, seed + ): + """The cohort with no recognised speed key still has a readable odometer. + + An odometer increase is one of the three movement proofs, so a device + reporting a bare `ODOMETER` autopid and nothing else recognisable is + readable and must not be named. + """ + vin, device = await make_vehicle("9") + await seed(vin, device.device_id, [("0C-ENGINERPM", 1), ("ODOMETER", 1)]) + + flagged = await LiveLinkService(db_session).movement_unreadable_device_ids([device]) + + assert device.device_id not in flagged + + async def test_a_disabled_device_is_not_flagged(self, db_session, make_vehicle, seed): + """Nothing is expected of it, so nothing is wrong with it.""" + vin, device = await make_vehicle("4") + device.enabled = False + await db_session.flush() + await seed(vin, device.device_id, [("0C-ENGINERPM", 1)]) + + flagged = await LiveLinkService(db_session).movement_unreadable_device_ids([device]) + + assert device.device_id not in flagged + + async def test_a_device_quiet_for_longer_than_the_window_is_not_flagged( + self, db_session, make_vehicle, seed + ): + """A dongle in a drawer is not a misconfiguration to act on. + + Its `last_seen` is stale, which is the cheap half of the test: it is + excluded before any query runs. + """ + vin, device = await make_vehicle("5") + device.last_seen = utc_now() - timedelta(days=60) + await db_session.flush() + await seed(vin, device.device_id, [("0C-ENGINERPM", 60)]) + + flagged = await LiveLinkService(db_session).movement_unreadable_device_ids([device]) + + assert device.device_id not in flagged + + async def test_a_never_seen_device_is_still_asked_about(self, db_session, make_vehicle, seed): + """An unset `last_seen` is not proof of an unused device. + + SD-card backfill inserts telemetry without going through + `store_telemetry`, so a dongle that only ever delivers off the card has + engine telemetry on record and no `last_seen` at all. Excluding it as + dormant would hide exactly the away-from-home cohort. + """ + vin, device = await make_vehicle("8") + assert device.last_seen is None + await seed(vin, device.device_id, [("0C-ENGINERPM", 1)]) + + flagged = await LiveLinkService(db_session).movement_unreadable_device_ids([device]) + + assert device.device_id in flagged + + async def test_one_query_covers_every_device(self, db_session, make_vehicle, seed): + """Two devices, opposite answers, decided together. + + The settings page asks about the whole fleet at once, so this must not + become a query per device. + """ + vin_a, device_a = await make_vehicle("6") + vin_b, device_b = await make_vehicle("7") + await seed(vin_a, device_a.device_id, [("0C-ENGINERPM", 1)]) + await seed(vin_b, device_b.device_id, [("BATTERY_VOLTAGE", 1)]) + + flagged = await LiveLinkService(db_session).movement_unreadable_device_ids( + [device_a, device_b] + ) + + assert flagged == {device_a.device_id} diff --git a/backend/tests/unit/services/test_refresh_aggregates_clearing.py b/backend/tests/unit/services/test_refresh_aggregates_clearing.py new file mode 100644 index 00000000..b140d764 --- /dev/null +++ b/backend/tests/unit/services/test_refresh_aggregates_clearing.py @@ -0,0 +1,243 @@ +"""Narrowing a session's window must not leave figures from the wider one. + +`_calculate_session_aggregates` assigns only when it finds samples (`count > 0`) +and never clears. That is deliberate and correct for the SCHEDULED refresh: +telemetry is pruned on a retention schedule while sessions are kept forever, so +an old session's window is legitimately empty and blanking it would erase real +history that was computed when the drive closed. + +It is exactly wrong for a tool that REBOUNDS a session. Narrowing a window from +95 minutes of parked heartbeats down to the four minutes the vehicle actually +moved, and then leaving `avg_speed` at the value the wide window produced, is +the same class of defect PR #157 fixed -- reintroduced by the tool meant to +consolidate it. + +So the two callers want opposite things, and the difference is a parameter +rather than a judgement call at the call site. Both directions get a test, +because a `clear_first` that defaults to True would silently blank every old +session on the next scheduler tick, which is a far larger bug than the one it +was added to fix. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.vehicle_telemetry import VehicleTelemetry +from app.services.session_service import SessionService + +pytestmark = pytest.mark.asyncio + +T0 = datetime(2026, 9, 1, 8, 0, 0) + + +class TestTheScheduledRefreshNeverBlanks: + async def test_an_empty_window_keeps_its_stored_figures( + self, db_session: AsyncSession, make_closed_drive_session + ): + """A session older than the retention horizon has no telemetry left. + + Its stored figures are the only record of that drive, and they were + correct when it closed. + """ + vin, device_id, session = await make_closed_drive_session( + "refclear", "1", started_at=T0, ended_at=T0 + timedelta(minutes=20) + ) + session.max_speed = 88.0 + session.distance_km = 42.0 + await db_session.flush() + + await SessionService(db_session).refresh_aggregates(session) + await db_session.flush() + + assert session.max_speed == 88.0 + assert session.distance_km == 42.0 + + +class TestClearFirstBlanksThenRecomputes: + async def test_stale_figures_from_a_wider_window_are_dropped( + self, db_session: AsyncSession, make_closed_drive_session + ): + """The rebound case: the window shrinks and the fast samples fall out.""" + vin, device_id, session = await make_closed_drive_session( + "refclear", "2", started_at=T0, ended_at=T0 + timedelta(minutes=60) + ) + for minute, speed in ((5, 90.0), (50, 20.0)): + db_session.add( + VehicleTelemetry( + vin=vin, + device_id=device_id, + param_key="SPEED", + value=speed, + timestamp=T0 + timedelta(minutes=minute), + ) + ) + await db_session.flush() + + service = SessionService(db_session) + await service.refresh_aggregates(session) + await db_session.flush() + assert session.max_speed == 90.0, "the wide window sees the fast sample" + + # Rebound to a window that excludes the 90 km/h sample. + session.started_at = T0 + timedelta(minutes=40) + session.ended_at = T0 + timedelta(minutes=60) + await service.refresh_aggregates(session, clear_first=True) + await db_session.flush() + + assert session.max_speed == 20.0, ( + "without clearing, the narrowed session keeps 90 km/h from a window it no longer covers" + ) + + async def test_clearing_leaves_none_when_the_new_window_is_empty( + self, db_session: AsyncSession, make_closed_drive_session + ): + """A rebound onto an empty window must say "unknown", not repeat itself. + + This is the honest outcome and it is why `clear_first` is opt-in: the + same behaviour applied to the scheduled refresh would erase history. + """ + vin, device_id, session = await make_closed_drive_session( + "refclear", "3", started_at=T0, ended_at=T0 + timedelta(minutes=20) + ) + session.max_speed = 77.0 + session.avg_speed = 44.0 + session.distance_km = 30.0 + await db_session.flush() + + await SessionService(db_session).refresh_aggregates(session, clear_first=True) + await db_session.flush() + + assert session.max_speed is None + assert session.avg_speed is None + assert session.distance_km is None + + async def test_the_default_is_not_to_clear(self, db_session: AsyncSession): + """Pinned explicitly. A default of True would blank every pruned session + on the next scheduler tick -- a far larger bug than the one clearing + was added to fix, and one that destroys data rather than misreporting.""" + import inspect + + signature = inspect.signature(SessionService.refresh_aggregates) + assert signature.parameters["clear_first"].default is False + + +class TestTheColumnListIsComplete: + """`_DERIVED_SESSION_COLUMNS` is a hand-written list, so it is a floor. + + A column added to `_calculate_session_aggregates`' mapping table but not to + that list would survive a rebound as a stale figure from the wider window, + silently, because nothing else reads the list. + + **The first version of this test could not fail.** It built its candidate + set BY ITERATING `_DERIVED_SESSION_COLUMNS`, so a column removed from the + list was simply never examined -- and deleting `avg_coolant_temp` from the + list left all five tests green. Measured, not hypothesised. + + So the candidate set is enumerated from the ORM instead, independently of + the list under test: every session column that is not identity, timing or + provenance is a candidate, whatever the list says. + """ + + #: Columns that are NOT derived from the window, so a refresh must not + #: touch them. Everything else on the model is a candidate, which is what + #: makes this enumeration independent of the list under test: a new derived + #: column is picked up by being absent from here rather than present there. + NOT_DERIVED = frozenset( + { + "id", + "vin", + "device_id", + "started_at", + "ended_at", + "duration_seconds", + "created_at", + "external_session_id", + "movement_started_at", + "movement_ended_at", + "boundary_algorithm_version", + "effective_gap_minutes", + # Never computed by `refresh_aggregates` -- no path writes it. + "fuel_used_estimate", + } + ) + + async def test_every_column_the_recompute_writes_can_be_cleared( + self, db_session: AsyncSession, make_closed_drive_session + ): + from app.models.drive_session import DriveSession + from app.services.session_service import _DERIVED_SESSION_COLUMNS + + candidates = sorted( + name for name in DriveSession.__table__.columns.keys() if name not in self.NOT_DERIVED + ) + assert candidates, "the exclusion list swallowed every column" + + vin, device_id, session = await make_closed_drive_session( + "refcols", "1", started_at=T0, ended_at=T0 + timedelta(minutes=20) + ) + # One sample of every quantity the aggregate mapping covers, plus two + # SPEED samples so the driving-insight columns are reached as well. + samples = [ + (1, "SPEED", 40.0), + (2, "SPEED", 55.0), + (2, "ENGINE_RPM", 2100.0), + (2, "COOLANT_TMP", 88.0), + (2, "THROTTLE", 30.0), + (2, "FUEL", 60.0), + (1, "A6-ODOMETER", 10_000.0), + (3, "A6-ODOMETER", 10_005.0), + ] + for minute, key, value in samples: + db_session.add( + VehicleTelemetry( + vin=vin, + device_id=device_id, + param_key=key, + value=value, + timestamp=T0 + timedelta(minutes=minute), + ) + ) + await db_session.flush() + + service = SessionService(db_session) + # Blank every CANDIDATE, so "not None afterwards" means "the recompute + # wrote it" rather than "the fixture set it". + for column in candidates: + setattr(session, column, None) + await service.refresh_aggregates(session) + await db_session.flush() + + written = {column for column in candidates if getattr(session, column) is not None} + assert len(written) >= 10, ( + "guard on the guard: the fixture must exercise most of the recompute, " + f"or the check below is vacuous. Wrote: {sorted(written)}" + ) + assert written <= set(_DERIVED_SESSION_COLUMNS), ( + "the recompute writes columns the clear list does not know about, so " + "they would survive a rebound as stale figures: " + f"{sorted(written - set(_DERIVED_SESSION_COLUMNS))}" + ) + + # And prove the clearing reaches all of them. A sentinel per column, + # because asserting `is None` would be asserting something false: + # `_calculate_driving_insights` legitimately recomputes `idle_seconds`, + # `harsh_accel_count` and `harsh_brake_count` to ZERO on a window with + # fewer than two speed samples. Those are fresh values, not survivors. + sentinel = -987.0 + for column in written: + setattr(session, column, sentinel) + + session.started_at = T0 + timedelta(days=400) + session.ended_at = T0 + timedelta(days=400, minutes=10) + await service.refresh_aggregates(session, clear_first=True) + await db_session.flush() + + survivors = sorted(column for column in written if getattr(session, column) == sentinel) + assert survivors == [], ( + f"these columns survived a rebound onto an empty window, so they " + f"would keep figures from the wider one: {survivors}" + ) diff --git a/backend/tests/unit/services/test_reminder_service.py b/backend/tests/unit/services/test_reminder_service.py index e82df065..7df7939d 100644 --- a/backend/tests/unit/services/test_reminder_service.py +++ b/backend/tests/unit/services/test_reminder_service.py @@ -702,14 +702,28 @@ async def test_naive_last_notified_at_within_cooldown_is_not_renotified( reminder_type="hours", due_hours=Decimal("500.0"), status="pending", - last_notified_at=datetime.now(UTC) - timedelta(hours=1), + # NAIVE, which is what the column actually holds on both dialects. + # + # This was seeded AWARE, relying on SQLite's bind processor to drop + # the tzinfo on write. PostgreSQL does not: asyncpg rejects an aware + # value for `TIMESTAMP WITHOUT TIME ZONE` outright, and because this + # suite shares one session the failed flush then poisoned it, taking + # two unrelated tests in `test_fuel_hours_economy.py` down with a + # PendingRollbackError. Invisible in CI, which runs only + # tests/migrations and tests/integration under PostgreSQL. + # + # The precondition under test is "the stored value is naive", and + # seeding it naive establishes that directly rather than by relying + # on one dialect's silent coercion. + last_notified_at=datetime.now(UTC).replace(tzinfo=None) - timedelta(hours=1), ) db_session.add(reminder) await db_session.commit() await db_session.refresh(reminder) - # Confirms the setup actually reproduces the bug's precondition: - # the value written as UTC-aware round-trips through SQLite naive. + # The precondition, asserted rather than assumed: without a naive stored + # value there is no TypeError for `check_due_reminders` to avoid, and + # this test would pass against the unfixed code. assert reminder.last_notified_at.tzinfo is None sent_messages: list[str] = [] diff --git a/backend/tests/unit/services/test_sd_backfill_creates_sessions.py b/backend/tests/unit/services/test_sd_backfill_creates_sessions.py new file mode 100644 index 00000000..1ad398b1 --- /dev/null +++ b/backend/tests/unit/services/test_sd_backfill_creates_sessions.py @@ -0,0 +1,410 @@ +"""An SD-card backfill creates the sessions its rows describe. C10. + +This is the motivating case for the whole boundary rework, and the case the +design's first revision did not fix. + +``bulk_backfill`` called only ``_refresh_sessions_in_span``, which selects +``WHERE DriveSession.ended_at IS NOT NULL`` -- sessions that **already exist and +are already closed**. It had never created one. Every decision in that first +revision changed live ingest, and the SD card is the only path for anything +driven out of broker range: off home WiFi the WiCAN reaches no broker at all, so +a whole drive arrives here hours later. On 2026-09-01 the Ram drove 16.0 km and +was credited 3.0 across three sessions; the Mirage drove 10.0 km and was +credited 0.0 across fifteen, thirteen of which held no telemetry at all. + +Two constraints shape the implementation and each has a test: + +**Once per call, not once per row.** Running the live side-effects per row is +precisely what ``bulk_backfill`` exists to avoid, and a pull is tens of +thousands of rows. + +**The span must not be narrowed to inserted rows.** It is built from all +*parsed* rows on purpose: the rows commit in batches here while +``SdBackfillService`` saves the file watermark only after this returns, so a +crash between the two leaves the rows imported and their sessions never +recomputed. The retry re-parses, every row conflicts, and a span built from +inserts would be empty -- losing the refresh permanently. An earlier design +revision described this as a "quadratic" defect and would have had an +implementer narrow exactly that. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.drive_session import DriveSession +from app.services.sd_log_parser import SdRow +from app.services.telemetry_service import TelemetryService + +pytestmark = pytest.mark.asyncio + +T0 = datetime(2026, 9, 1, 8, 0, 0) +GAP = 15 + + +async def _sessions(db: AsyncSession, device_id: str) -> list[DriveSession]: + return list( + ( + await db.execute( + select(DriveSession) + .where(DriveSession.device_id == device_id) + .order_by(DriveSession.started_at) + ) + ) + .scalars() + .all() + ) + + +def _rows(*specs: tuple[int, str, float]) -> list[SdRow]: + """`(minute_offset, param_key, value)` triples as SdRows.""" + return [ + SdRow(param_key=key, value=value, timestamp=T0 + timedelta(minutes=offset)) + for offset, key, value in specs + ] + + +class TestItCreatesSessions: + async def test_a_replayed_drive_creates_a_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The Ram's missing kilometres, in the smallest form that shows them.""" + vin, device = await make_livelink_vehicle("sdcreate", "1") + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows( + (0, "A6-ODOMETER", 50_000.0), + (1, "SPEED", 45.0), + (2, "SPEED", 62.0), + (3, "A6-ODOMETER", 50_004.0), + (4, "SPEED", 40.0), + ), + ) + + sessions = await _sessions(db_session, device.device_id) + assert len(sessions) == 1, "the SD path has never created a session before this" + assert sessions[0].boundary_algorithm_version == 1 + assert sessions[0].effective_gap_minutes == GAP + assert sessions[0].ended_at is not None, "a replayed drive is over; it must not be open" + + async def test_the_created_session_gets_its_distance( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Creating the row is only half of it: the point is the credited distance.""" + vin, device = await make_livelink_vehicle("sdcreate", "2") + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows( + (0, "A6-ODOMETER", 50_000.0), + (1, "SPEED", 45.0), + (5, "SPEED", 55.0), + (9, "A6-ODOMETER", 50_013.0), + ), + ) + + session = (await _sessions(db_session, device.device_id))[0] + assert session.distance_km == pytest.approx(13.0) + + async def test_the_window_keeps_the_opening_odometer( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """C5 applies to replay too, or the two paths disagree about one drive.""" + vin, device = await make_livelink_vehicle("sdcreate", "3") + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows( + (0, "ENGINE_RPM", 700.0), + (0, "A6-ODOMETER", 60_000.0), + (3, "SPEED", 50.0), + (4, "SPEED", 50.0), + (8, "A6-ODOMETER", 60_009.0), + ), + ) + + session = (await _sessions(db_session, device.device_id))[0] + assert session.started_at == T0, "the ignition-time burst must be in the window" + assert session.start_odometer == 60_000.0 + + async def test_two_drives_separated_by_the_gap_become_two_sessions( + self, db_session: AsyncSession, make_livelink_vehicle + ): + vin, device = await make_livelink_vehicle("sdcreate", "4") + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows( + (0, "SPEED", 45.0), + (1, "SPEED", 50.0), + # 40 minutes parked, well past the 15-minute drive gap. + (41, "SPEED", 48.0), + (42, "SPEED", 52.0), + ), + ) + + sessions = await _sessions(db_session, device.device_id) + assert len(sessions) == 2 + + async def test_a_stop_inside_the_gap_stays_one_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The same rule the live path uses, so a drive is cut the same way + whichever path it arrived by. Scoping the gap to reconstruction only -- + which an earlier revision did -- means one journey gets two different + answers depending on how it reached the database.""" + vin, device = await make_livelink_vehicle("sdcreate", "5") + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows( + (0, "SPEED", 45.0), + (1, "SPEED", 50.0), + (8, "SPEED", 48.0), # 7 minutes later: inside the gap + (9, "SPEED", 52.0), + ), + ) + + assert len(await _sessions(db_session, device.device_id)) == 1 + + +class TestItRefusesToInventDrives: + async def test_a_parked_heartbeat_file_creates_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """A month of SD rows from a parked vehicle must stay a month of nothing. + + This is the assertion that makes the rest safe: a replay path that + creates a session per contact burst would manufacture thousands of + phantom drives from history, which is strictly worse than the bug being + fixed because there is no upgrade that undoes it. + """ + vin, device = await make_livelink_vehicle("sdrefuse", "1") + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows(*[(m * 95, "BATTERY_VOLTAGE", 12.4) for m in range(20)]), + ) + + assert await _sessions(db_session, device.device_id) == [] + + async def test_an_idle_only_file_creates_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + vin, device = await make_livelink_vehicle("sdrefuse", "2") + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows(*[(m, "ENGINE_RPM", 750.0) for m in range(12)]), + ) + + assert await _sessions(db_session, device.device_id) == [] + + async def test_a_single_speed_spike_creates_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The same debounce the live path applies. One sample is not a drive.""" + vin, device = await make_livelink_vehicle("sdrefuse", "3") + + await TelemetryService(db_session).bulk_backfill( + vin, device.device_id, _rows((0, "SPEED", 48.0)) + ) + + assert await _sessions(db_session, device.device_id) == [] + + async def test_an_unchanged_odometer_creates_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + vin, device = await make_livelink_vehicle("sdrefuse", "4") + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows((0, "A6-ODOMETER", 70_000.0), (95, "A6-ODOMETER", 70_000.0)), + ) + + assert await _sessions(db_session, device.device_id) == [] + + async def test_an_unlinked_device_creates_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + vin, device = await make_livelink_vehicle("sdrefuse", "5") + device.vin = None + await db_session.flush() + + await TelemetryService(db_session).bulk_backfill( + vin, device.device_id, _rows((0, "SPEED", 45.0), (1, "SPEED", 50.0)) + ) + + assert await _sessions(db_session, device.device_id) == [] + + +class TestItDoesNotOverlapExistingSessions: + async def test_a_drive_already_recorded_is_extended_not_duplicated( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The common case on a device with intermittent WiFi. + + Part of the drive reached the broker live and opened a session; the rest + arrives off the SD card. Creating a second session for the same journey + would leave two overlapping windows, and every aggregate here is a + window scan -- so both would claim the same samples and both report the + same distance, doubling the vehicle's apparent mileage. + """ + vin, device = await make_livelink_vehicle("sdoverlap", "1") + live_part = DriveSession( + vin=vin, + device_id=device.device_id, + started_at=T0, + ended_at=T0 + timedelta(minutes=2), + boundary_algorithm_version=1, + effective_gap_minutes=GAP, + ) + db_session.add(live_part) + await db_session.flush() + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows( + (0, "A6-ODOMETER", 80_000.0), + (1, "SPEED", 45.0), + (2, "SPEED", 50.0), + (6, "SPEED", 55.0), + (7, "A6-ODOMETER", 80_011.0), + ), + ) + + sessions = await _sessions(db_session, device.device_id) + assert len(sessions) == 1, f"expected the session to be extended, got {len(sessions)}" + assert sessions[0].id == live_part.id + assert sessions[0].ended_at >= T0 + timedelta(minutes=6) + assert sessions[0].distance_km == pytest.approx(11.0) + + async def test_a_torque_session_is_never_extended( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Torque supplies an authoritative session id from the phone. + + Re-bounding it on a gap threshold replaces good evidence with inference. + Excluded by `external_session_id`, not by heuristic. + """ + vin, device = await make_livelink_vehicle("sdoverlap", "2") + torque = DriveSession( + vin=vin, + device_id=device.device_id, + started_at=T0, + ended_at=T0 + timedelta(minutes=2), + external_session_id="phone-1", + ) + db_session.add(torque) + await db_session.flush() + original_end = torque.ended_at + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows((1, "SPEED", 45.0), (2, "SPEED", 50.0), (6, "SPEED", 55.0)), + ) + + await db_session.refresh(torque) + assert torque.ended_at == original_end, "a phone-bounded session must not be re-bounded" + + async def test_no_two_output_sessions_overlap( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Nothing in the schema forbids overlapping windows -- the time indexes + are non-unique -- so this is asserted rather than assumed.""" + vin, device = await make_livelink_vehicle("sdoverlap", "3") + + await TelemetryService(db_session).bulk_backfill( + vin, + device.device_id, + _rows( + (0, "SPEED", 45.0), + (1, "SPEED", 50.0), + (40, "SPEED", 48.0), + (41, "SPEED", 52.0), + (90, "SPEED", 44.0), + (91, "SPEED", 46.0), + ), + ) + + sessions = await _sessions(db_session, device.device_id) + assert len(sessions) == 3 + for earlier, later in zip(sessions, sessions[1:], strict=False): + assert earlier.ended_at <= later.started_at, ( + f"session {earlier.id} ends {earlier.ended_at}, " + f"session {later.id} starts {later.started_at}" + ) + + +class TestItRunsOncePerCall: + async def test_reconstruction_runs_once_regardless_of_row_count( + self, db_session: AsyncSession, make_livelink_vehicle, monkeypatch + ): + """Running the live side-effects per row is what this method exists to + avoid, and a real pull is tens of thousands of rows.""" + vin, device = await make_livelink_vehicle("sdonce", "1") + service = TelemetryService(db_session) + + calls = [] + original = service._reconstruct_sessions_from_batch + + async def counting(*args, **kwargs): + calls.append(args) + return await original(*args, **kwargs) + + monkeypatch.setattr(service, "_reconstruct_sessions_from_batch", counting) + + rows = _rows(*[(m, "SPEED", 45.0 + m) for m in range(60)]) + await service.bulk_backfill(vin, device.device_id, rows) + + assert len(calls) == 1, f"called {len(calls)} times for {len(rows)} rows" + + async def test_the_span_is_built_from_parsed_rows_not_inserted_ones( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Re-running an identical batch must still refresh, though it inserts 0. + + The rows commit in batches here while `SdBackfillService` saves the file + watermark only after this returns. A crash in between leaves the rows + imported and their sessions never recomputed; the retry re-parses, every + row conflicts, and a span built from INSERTS would be empty -- losing + the refresh permanently rather than redoing it. + """ + vin, device = await make_livelink_vehicle("sdonce", "2") + service = TelemetryService(db_session) + rows = _rows( + (0, "A6-ODOMETER", 90_000.0), + (1, "SPEED", 45.0), + (2, "SPEED", 50.0), + (5, "A6-ODOMETER", 90_007.0), + ) + + inserted_first = await service.bulk_backfill(vin, device.device_id, rows) + assert inserted_first > 0 + + session = (await _sessions(db_session, device.device_id))[0] + session.distance_km = 999.0 # a stale figure a refresh must correct + await db_session.flush() + + inserted_again = await service.bulk_backfill(vin, device.device_id, rows) + assert inserted_again == 0, "every row should have conflicted" + + await db_session.refresh(session) + assert session.distance_km == pytest.approx(7.0), ( + "the refresh was skipped, which is what a span built from inserts does" + ) diff --git a/backend/tests/unit/services/test_session_boundaries.py b/backend/tests/unit/services/test_session_boundaries.py new file mode 100644 index 00000000..9fb55921 --- /dev/null +++ b/backend/tests/unit/services/test_session_boundaries.py @@ -0,0 +1,114 @@ +"""The movement predicate, in isolation from the database. + +Everything here is a pure function of one telemetry batch. The stateful half -- +debounce, pending drives, expiry -- lives in `SessionService` and is tested +against real rows in `tests/integration/test_session_state_machine.py`; this +file pins the part that decides what a batch of readings *means*. + +The design's first revision said `speed > 0 OR rpm > 0`. Three things are wrong +with that, and each has a test below: + +1. **No floor.** A single 1 km/h sample opens a session, and that sample is + effectively unvalidatable: `validate_rate_of_change` skips entirely when the + previous reading is older than 120 seconds, which is exactly the + parked-heartbeat case. +2. **RPM is not movement.** An engine running with the vehicle stationary is a + remote start, a diagnostic session, a winter warm-up, or the eleven-minute + driveway idle that was credited 14 km and started this whole rework. +3. **No odometer signal.** The one signal that covers a device whose speed + arrives under a name nothing recognises. Without it, C7's "refuse to + rebuild without positive evidence" would erase that whole cohort. +""" + +from __future__ import annotations + +import pytest + +from app.services.session_boundaries import ( + MOVEMENT_FLOOR_KMH, + MovementSignals, + extract_signals, +) + + +class TestSignalExtraction: + def test_speed_is_read_under_every_spelling(self): + for key in ("SPEED", "0D-VEHICLESPEED", "VEHICLE_SPEED"): + assert extract_signals({key: 42.0}).speed_kmh == 42.0 + + def test_rpm_is_read_under_every_spelling(self): + for key in ("RPM", "ENGINE_RPM", "0C-ENGINERPM"): + assert extract_signals({key: 800.0}).rpm == 800.0 + + def test_odometer_is_read(self): + assert extract_signals({"A6-ODOMETER": 120_345.0}).odometer_km == 120_345.0 + + def test_a_battery_heartbeat_carries_no_signal_at_all(self): + """The exact payload that opened 2,975 phantom sessions.""" + signals = extract_signals({"BATTERY_VOLTAGE": 12.4}) + assert signals == MovementSignals(speed_kmh=None, rpm=None, odometer_km=None) + assert not signals.has_any_signal + + def test_the_highest_speed_in_a_batch_wins(self): + """A batch can carry the same quantity under two keys during a firmware + change. Taking the max is the safe direction: under-reading speed loses + a real drive, over-reading it at worst opens a session a stop would.""" + assert extract_signals({"SPEED": 3.0, "0D-VEHICLESPEED": 61.0}).speed_kmh == 61.0 + + def test_non_numeric_and_missing_values_are_ignored(self): + assert extract_signals({"SPEED": None, "RPM": "n/a"}).speed_kmh is None + + def test_unrelated_keys_do_not_leak_into_signals(self): + signals = extract_signals( + {"COOLANT_TMP": 90.0, "THROTTLE": 30.0, "21-DISTANCEMILON": 400.0} + ) + assert not signals.has_any_signal + + +class TestTheFloor: + def test_the_floor_is_the_idle_threshold_already_in_use(self): + """Not a new constant. + + `_calculate_driving_insights` has defined not-moving as `< 5 km/h` for + idle accounting since the session code was written. A separate `> 0` + movement rule would have put a second, contradictory definition of + "moving" twelve lines from the first. + """ + from app.services.session_service import IDLE_THRESHOLD_KMH + + assert MOVEMENT_FLOOR_KMH == IDLE_THRESHOLD_KMH + + @pytest.mark.parametrize("speed", [0.0, 0.9, 1.0, 4.9]) + def test_speed_below_the_floor_is_not_movement(self, speed: float): + assert not extract_signals({"SPEED": speed}).is_above_floor + + @pytest.mark.parametrize("speed", [5.0, 5.1, 60.0]) + def test_speed_at_or_above_the_floor_is_movement(self, speed: float): + assert extract_signals({"SPEED": speed}).is_above_floor + + def test_a_missing_speed_is_not_above_the_floor(self): + assert not extract_signals({"RPM": 2200.0}).is_above_floor + + +class TestEngineOnIsNotMovement: + def test_rpm_alone_is_engine_on(self): + signals = extract_signals({"ENGINE_RPM": 780.0}) + assert signals.is_engine_on + assert not signals.is_above_floor + + def test_zero_rpm_is_not_engine_on(self): + assert not extract_signals({"ENGINE_RPM": 0.0}).is_engine_on + + def test_an_idling_ice_vehicle_shows_engine_on_and_no_movement(self): + """The original complaint: a Ram idling in a driveway for eleven minutes + at a top speed of 2 km/h, credited with 14 km.""" + signals = extract_signals({"ENGINE_RPM": 750.0, "SPEED": 2.0}) + assert signals.is_engine_on + assert not signals.is_above_floor + + def test_an_ev_under_way_shows_movement_and_no_engine(self): + """An EV reports no RPM at all. An RPM-only predicate gives it zero + sessions, forever, with nothing in the log to say why.""" + signals = extract_signals({"SPEED": 55.0}) + assert signals.is_above_floor + assert not signals.is_engine_on diff --git a/backend/tests/unit/services/test_session_boundaries_scenario.py b/backend/tests/unit/services/test_session_boundaries_scenario.py new file mode 100644 index 00000000..fac260cf --- /dev/null +++ b/backend/tests/unit/services/test_session_boundaries_scenario.py @@ -0,0 +1,174 @@ +"""The measured production day, replayed end to end. + +Every other test in this change exercises one rule. This one replays the pattern +that motivated the whole rework and asserts the outcome a user would describe, +because a set of individually correct rules can still compose into a wrong day. + +The pattern is from a real instance on 2026-09-01, not invented: + +- the Mirage sat parked and published a battery-voltage heartbeat about every 95 + minutes, and every recorded session began within 0.1s of one, twelve for + twelve; +- fifteen sessions were recorded, thirteen of which held no telemetry at all; +- the vehicle actually drove 10.0 km, and was credited 0.0. + +Under the old rule this day is fifteen drives and no distance. It should be one +drive with its distance, and nothing else. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.drive_session import DriveSession +from app.services import telemetry_service as telemetry_module +from app.services.session_service import SessionService +from app.services.telemetry_service import TelemetryService + +pytestmark = pytest.mark.asyncio + +DAY = datetime(2026, 9, 1, 0, 0, 0) +HEARTBEAT_MINUTES = 95 +GAP = 15 +TIMEOUT = 5 + + +async def _sessions(db: AsyncSession, device_id: str) -> list[DriveSession]: + return list( + ( + await db.execute( + select(DriveSession) + .where(DriveSession.device_id == device_id) + .order_by(DriveSession.started_at) + ) + ) + .scalars() + .all() + ) + + +class TestAMeasuredDay: + async def test_a_parked_day_records_no_drives_and_one_trip_records_one( + self, db_session: AsyncSession, make_livelink_vehicle, monkeypatch + ): + vin, device = await make_livelink_vehicle("scenario", "1") + service = SessionService(db_session) + + # Through `store_telemetry`, the real ingest entry point that MQTT and + # HTTPS both funnel into, rather than calling the observer directly. + # Driving the observer by hand would test the state machine and skip the + # wiring, and the wiring is where a previous design revision hooked only + # one of three paths. + telemetry = TelemetryService(db_session) + + async def feed(at: datetime, samples: dict) -> None: + # `store_telemetry` compares the sample time against its own + # `utc_now()` to decide whether a reading is live or a replay, so the + # clock has to move with the scenario or every payload here reads as + # a year-old replay. + monkeypatch.setattr(telemetry_module, "utc_now", lambda: at) + device.last_seen = at + await telemetry.store_telemetry( + vin=vin, + device_id=device.device_id, + autopid_data=dict(samples), + config={}, + timestamp=at, + ) + await service.check_session_timeouts(timeout_minutes=TIMEOUT, gap_minutes=GAP, now=at) + await db_session.flush() + + # 00:00 to 14:00 parked: nine heartbeats at the measured interval. + minute = 0 + while minute < 14 * 60: + await feed(DAY + timedelta(minutes=minute), {"BATTERY_VOLTAGE": 12.4}) + minute += HEARTBEAT_MINUTES + + assert await _sessions(db_session, device.device_id) == [], ( + "a parked morning recorded a drive; under the old rule this alone " + "produced nine of the day's fifteen sessions" + ) + + # 14:00 the vehicle is started, warms up for two minutes, then drives + # 10.0 km, stopping once at a light. + drive_start = DAY + timedelta(hours=14) + await feed(drive_start, {"ENGINE_RPM": 700, "SPEED": 0, "A6-ODOMETER": 40_000.0}) + await feed(drive_start + timedelta(minutes=1), {"ENGINE_RPM": 900, "SPEED": 0}) + await feed(drive_start + timedelta(minutes=2), {"SPEED": 35, "ENGINE_RPM": 1800}) + await feed(drive_start + timedelta(minutes=3), {"SPEED": 52, "ENGINE_RPM": 2200}) + await feed(drive_start + timedelta(minutes=5), {"SPEED": 0, "ENGINE_RPM": 800}) + await feed(drive_start + timedelta(minutes=6), {"SPEED": 48, "ENGINE_RPM": 2000}) + await feed( + drive_start + timedelta(minutes=12), + {"SPEED": 30, "ENGINE_RPM": 1500, "A6-ODOMETER": 40_010.0}, + ) + + # Parked again for the evening, still checking in. + minute = 14 * 60 + HEARTBEAT_MINUTES + while minute < 24 * 60: + await feed(DAY + timedelta(minutes=minute), {"BATTERY_VOLTAGE": 12.4}) + minute += HEARTBEAT_MINUTES + + sessions = await _sessions(db_session, device.device_id) + assert len(sessions) == 1, f"the day recorded {len(sessions)} drives; the vehicle made one" + + drive = sessions[0] + assert drive.started_at == drive_start, ( + "the window must open at the ignition burst, which is where the " + "opening odometer reading is" + ) + assert drive.ended_at == drive_start + timedelta(minutes=12), ( + "the drive must end at the last movement, not at the evening's " + "heartbeats, which would pad it by hours" + ) + assert drive.distance_km == pytest.approx(10.0), ( + "this is the 10.0 km that was credited as 0.0" + ) + assert drive.max_speed == pytest.approx(52.0) + assert drive.boundary_algorithm_version == 1 + + async def test_the_light_stop_did_not_split_the_drive( + self, db_session: AsyncSession, make_livelink_vehicle, monkeypatch + ): + """Stated separately because it is the thing the two clocks buy. + + A one-minute stop is inside both clocks; the assertion that matters is + that the CONTACT clock, at five minutes, is not what decides it. So this + stops for eight minutes: past the contact timeout, inside the drive gap. + """ + vin, device = await make_livelink_vehicle("scenario", "2") + service = SessionService(db_session) + + telemetry = TelemetryService(db_session) + + async def feed(at: datetime, samples: dict) -> None: + monkeypatch.setattr(telemetry_module, "utc_now", lambda: at) + device.last_seen = at + await telemetry.store_telemetry( + vin=vin, + device_id=device.device_id, + autopid_data=dict(samples), + config={}, + timestamp=at, + ) + await service.check_session_timeouts(timeout_minutes=TIMEOUT, gap_minutes=GAP, now=at) + await db_session.flush() + + start = DAY + timedelta(hours=9) + await feed(start, {"SPEED": 40, "ENGINE_RPM": 1900}) + await feed(start + timedelta(minutes=1), {"SPEED": 55, "ENGINE_RPM": 2300}) + for offset in range(2, 10): + await feed(start + timedelta(minutes=offset), {"SPEED": 0, "ENGINE_RPM": 750}) + await feed(start + timedelta(minutes=10), {"SPEED": 45, "ENGINE_RPM": 2000}) + await feed(start + timedelta(minutes=11), {"SPEED": 50, "ENGINE_RPM": 2100}) + + sessions = await _sessions(db_session, device.device_id) + assert len(sessions) == 1, ( + "an eight-minute stop split the drive; that is the five-minute " + "connection-loss detector being used to end drives" + ) + assert sessions[0].ended_at is None diff --git a/backend/tests/unit/services/test_session_boundary_settings.py b/backend/tests/unit/services/test_session_boundary_settings.py new file mode 100644 index 00000000..74b2a9c7 --- /dev/null +++ b/backend/tests/unit/services/test_session_boundary_settings.py @@ -0,0 +1,359 @@ +"""The two new settings, and the diagnostic that keeps `contact` mode honest. + +**Why the gap is its own setting (C11).** An earlier design revision reused +``livelink_session_timeout_minutes`` and argued the two would then "agree by +construction". They would not: the live path also ends a session on explicit +ECU-offline plus a 60-second grace, and on Torque's own session id, so a +key-off/key-on pair 90 seconds apart splits live and merges on SD replay. +The questions are different -- "has this device gone quiet?" is not "was that +the same drive?" -- and conflating them means an admin cannot fix trip grouping +without also changing failure detection. + +**Why there is a reversal switch (C12).** The first revision argued against any +setting because it would "only keep producing phantom drives". That is wrong for +one cohort: a device whose movement signals nothing recognises produces **real** +drives under the contact rule and none at all under the movement rule. It also +gives an operator a way to bisect a bad upgrade where downgrading is impossible. + +**And why the switch needs a diagnostic.** A silent zero is the failure mode +this entire change exists to eliminate; shipping a mode that reintroduces it +without saying so would be absurd. A device that produces telemetry across a +window but never a movement signal is named in the log, with the keys it did +send, so the operator has something to act on rather than an empty session list. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.drive_session import DriveSession +from app.services.livelink_service import LiveLinkService +from app.services.session_service import SessionService +from app.services.settings_service import SettingsService + +pytestmark = pytest.mark.asyncio + +T0 = datetime(2026, 9, 1, 8, 0, 0) + +#: Every global key this module writes. These are GLOBAL settings, and the suite +#: shares one database with no per-test rollback, so a test that leaves +#: `livelink_session_boundary_mode = contact` behind silently switches off +#: movement boundaries for every test that runs after it. Measured: 28 failures +#: in files that never mention settings, all of them in `contact` mode without +#: knowing it. Restoring is not tidiness, it is the difference between this file +#: testing a switch and this file breaking the suite. +_GLOBAL_KEYS = ( + "livelink_session_gap_minutes", + "livelink_session_boundary_mode", + "livelink_session_timeout_minutes", +) + + +@pytest_asyncio.fixture(autouse=True) +async def restore_global_settings(db_session: AsyncSession): + """Snapshot and restore the global keys this module writes.""" + before = {} + for key in _GLOBAL_KEYS: + setting = await SettingsService.get(db_session, key) + before[key] = setting.value if setting else None + + yield + + for key, value in before.items(): + setting = await SettingsService.get(db_session, key) + if setting is None: + continue + if value is None: + await db_session.delete(setting) + else: + setting.value = value + # COMMIT, not flush: the route under test calls `db.commit()` itself, so a + # flushed restore is discarded by the next commit boundary and the poisoned + # value survives. This was measured -- 28 failures in files that never + # mention settings, all silently running in `contact` mode. + await db_session.commit() + + +async def _sessions(db: AsyncSession, device_id: str) -> list[DriveSession]: + return list( + (await db.execute(select(DriveSession).where(DriveSession.device_id == device_id))) + .scalars() + .all() + ) + + +class TestTheGapSetting: + async def test_it_defaults_to_fifteen_minutes(self, db_session: AsyncSession): + assert await LiveLinkService(db_session).get_session_gap_minutes() == 15 + + async def test_it_is_not_the_session_timeout(self, db_session: AsyncSession): + """Two settings, not one. Reusing the timeout would mean an admin + cannot fix trip grouping without also changing failure detection.""" + service = LiveLinkService(db_session) + await SettingsService.set(db_session, "livelink_session_timeout_minutes", "9") + await db_session.flush() + + assert await service.get_session_timeout_minutes() == 9 + assert await service.get_session_gap_minutes() == 15 + + async def test_a_configured_gap_changes_where_a_drive_is_cut( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The setting must reach the boundary logic, not just the getter. + + A setting that reads back correctly and changes no behaviour is the + commonest way a knob ships broken, and it looks identical to a working + one from the settings page. + """ + vin, device = await make_livelink_vehicle("gapset", "1") + await SettingsService.set(db_session, "livelink_session_gap_minutes", "3") + await db_session.flush() + service = SessionService(db_session) + + for at in (T0, T0 + timedelta(minutes=1)): + device.last_seen = at + await service.observe_telemetry(device, {"SPEED": 45.0}, at, live=True) + await db_session.flush() + + # Six minutes stationary: inside the default 15-minute gap, well past + # the configured 3-minute one. + stopped = T0 + timedelta(minutes=7) + device.last_seen = stopped + await service.observe_telemetry(device, {"SPEED": 0.0}, stopped, live=True) + await service.check_session_timeouts(timeout_minutes=60, now=stopped) + await db_session.flush() + + sessions = await _sessions(db_session, device.device_id) + assert len(sessions) == 1 + assert sessions[0].ended_at is not None, "the 3-minute gap should have closed this" + assert sessions[0].effective_gap_minutes == 3, ( + "the gap in force is recorded on the session, so a later " + "pass over history knows what it is looking at" + ) + + +class TestTheBoundaryModeSetting: + async def test_it_defaults_to_movement(self, db_session: AsyncSession): + assert await LiveLinkService(db_session).get_session_boundary_mode() == "movement" + + async def test_an_unrecognised_value_falls_back_to_movement(self, db_session: AsyncSession): + """A typo in the settings table must not silently disable the fix.""" + await SettingsService.set(db_session, "livelink_session_boundary_mode", "moovment") + await db_session.flush() + + assert await LiveLinkService(db_session).get_session_boundary_mode() == "movement" + + async def test_contact_mode_restores_the_old_rule( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """For a device whose signals nothing recognises, contact produces REAL + drives where movement produces none.""" + vin, device = await make_livelink_vehicle("modeset", "1") + await SettingsService.set(db_session, "livelink_session_boundary_mode", "contact") + await db_session.flush() + service = SessionService(db_session) + + opened = await service.handle_ecu_status_change(device, "online", T0) + await db_session.flush() + + assert opened is not None, "contact mode must open a session on ECU-online" + assert opened.boundary_algorithm_version == 0, ( + "a contact-bounded session must not claim the movement algorithm" + ) + + async def test_movement_mode_does_not(self, db_session: AsyncSession, make_livelink_vehicle): + """The paired control. Without it the test above passes in both modes.""" + vin, device = await make_livelink_vehicle("modeset", "2") + service = SessionService(db_session) + + opened = await service.handle_ecu_status_change(device, "online", T0) + await db_session.flush() + + assert opened is None + assert await _sessions(db_session, device.device_id) == [] + + async def test_contact_mode_ignores_telemetry_for_boundaries( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Both halves have to switch together, or the two rules run at once and + a single drive gets a contact-bounded session AND a movement-bounded + one, overlapping.""" + vin, device = await make_livelink_vehicle("modeset", "3") + await SettingsService.set(db_session, "livelink_session_boundary_mode", "contact") + await db_session.flush() + service = SessionService(db_session) + + for at in (T0, T0 + timedelta(minutes=1)): + await service.observe_telemetry(device, {"SPEED": 45.0}, at, live=True) + await db_session.flush() + + assert await _sessions(db_session, device.device_id) == [] + + +class TestTheNoMovementDiagnostic: + async def test_a_device_with_no_movement_signal_is_named( + self, db_session: AsyncSession, make_livelink_vehicle, caplog + ): + """A silent zero is the failure this whole change exists to eliminate. + + The log names the device and the keys it DID send, because "no sessions" + with an unknown cause is not something an operator can act on, while + "this device publishes MY_SPEED_PID and nothing recognises it" is. + """ + vin, device = await make_livelink_vehicle("diagset", "1") + service = SessionService(db_session) + + with caplog.at_level(logging.WARNING, logger="app.services.session_service"): + for minute in range(4): + at = T0 + timedelta(minutes=minute * 30) + device.last_seen = at + await service.observe_telemetry( + device, {"CUSTOM_ROAD_SPEED": 55.0, "COOLANT_TMP": 88.0}, at, live=True + ) + await db_session.flush() + + messages = [r.getMessage() for r in caplog.records] + assert any(device.device_id in m for m in messages), messages + assert any("CUSTOM_ROAD_SPEED" in m for m in messages), ( + "the keys it did send are the actionable part; naming the device alone " + f"tells an operator nothing they can fix: {messages}" + ) + + async def test_it_is_logged_once_not_per_payload( + self, db_session: AsyncSession, make_livelink_vehicle, caplog + ): + """A device sends a payload every few seconds. Logging per payload + floods the log and buries the diagnostic it is trying to surface.""" + vin, device = await make_livelink_vehicle("diagset", "2") + service = SessionService(db_session) + + with caplog.at_level(logging.WARNING, logger="app.services.session_service"): + for minute in range(30): + at = T0 + timedelta(minutes=minute) + device.last_seen = at + await service.observe_telemetry(device, {"CUSTOM_PID": 12.0}, at, live=True) + + warnings = [r for r in caplog.records if device.device_id in r.getMessage()] + assert len(warnings) == 1, f"logged {len(warnings)} times" + + async def test_a_device_that_reports_movement_is_not_flagged( + self, db_session: AsyncSession, make_livelink_vehicle, caplog + ): + """The control. Without it the assertions above are satisfied by a + diagnostic that fires for every device on every payload.""" + vin, device = await make_livelink_vehicle("diagset", "3") + service = SessionService(db_session) + + with caplog.at_level(logging.WARNING, logger="app.services.session_service"): + for minute in range(4): + at = T0 + timedelta(minutes=minute) + device.last_seen = at + await service.observe_telemetry(device, {"SPEED": 45.0}, at, live=True) + + assert [r.getMessage() for r in caplog.records if device.device_id in r.getMessage()] == [] + + async def test_a_parked_device_is_not_flagged( + self, db_session: AsyncSession, make_livelink_vehicle, caplog + ): + """A battery heartbeat is not a broken device. + + This is the distinction that makes the diagnostic worth having: a parked + vehicle SHOULD produce no sessions, and flagging it would make the + warning meaningless on every instance. + """ + vin, device = await make_livelink_vehicle("diagset", "4") + service = SessionService(db_session) + + with caplog.at_level(logging.WARNING, logger="app.services.session_service"): + for minute in range(5): + at = T0 + timedelta(minutes=minute * 95) + device.last_seen = at + await service.observe_telemetry(device, {"BATTERY_VOLTAGE": 12.4}, at, live=True) + + assert [r.getMessage() for r in caplog.records if device.device_id in r.getMessage()] == [] + + +class TestTheSettingsReachTheAdminApi: + """A setting the UI cannot edit is a setting that does not exist. + + Both are read from the settings table with a sensible default, so every + behavioural test above passes whether or not the admin surface knows about + them. That is exactly the gap where a knob ships unreachable: the code + honours it, the tests prove the code honours it, and no user can ever set + it. So the response and the update path each get an assertion. + """ + + async def test_the_response_reports_both(self, db_session: AsyncSession): + from app.schemas.livelink import LiveLinkSettingsResponse + + fields = LiveLinkSettingsResponse.model_fields + assert "session_gap_minutes" in fields + assert "session_boundary_mode" in fields + + async def test_the_update_schema_accepts_both(self, db_session: AsyncSession): + from app.schemas.livelink import LiveLinkSettingsUpdate + + parsed = LiveLinkSettingsUpdate(session_gap_minutes=20, session_boundary_mode="contact") + assert parsed.session_gap_minutes == 20 + assert parsed.session_boundary_mode == "contact" + + async def test_a_zero_gap_is_refused(self, db_session: AsyncSession): + """Zero closes a session on the first stationary sample, so every + traffic light becomes its own trip.""" + import pydantic + + from app.schemas.livelink import LiveLinkSettingsUpdate + + with pytest.raises(pydantic.ValidationError): + LiveLinkSettingsUpdate(session_gap_minutes=0) + + async def test_an_invalid_mode_is_refused_at_the_boundary(self, db_session: AsyncSession): + """The service also falls back to `movement` on a bad value, but that is + a safety net for hand-edited rows. The API should say no.""" + import pydantic + + from app.schemas.livelink import LiveLinkSettingsUpdate + + with pytest.raises(pydantic.ValidationError): + LiveLinkSettingsUpdate(session_boundary_mode="whatever") + + async def test_the_route_persists_the_gap(self, db_session: AsyncSession): + """Exercises the update branch itself, which is a hand-written if-chain + of thirteen near-identical blocks -- the shape where a new setting gets + its getter and its schema field and no `SettingsService.set` call.""" + # A real Request, because the route echoes the settings back through + # `get_livelink_settings`, which builds the WiCAN ingestion URL from it. + from starlette.requests import Request as StarletteRequest + + from app.routes.livelink_admin import update_livelink_settings + from app.schemas.livelink import LiveLinkSettingsUpdate + + request = StarletteRequest( + { + "type": "http", + "method": "PUT", + "path": "/api/v1/livelink/settings", + "headers": [(b"host", b"testserver")], + "scheme": "http", + "server": ("testserver", 80), + "query_string": b"", + } + ) + + await update_livelink_settings( + updates=LiveLinkSettingsUpdate(session_gap_minutes=25, session_boundary_mode="contact"), + request=request, + db=db_session, + current_user=None, + ) + await db_session.flush() + + service = LiveLinkService(db_session) + assert await service.get_session_gap_minutes() == 25 + assert await service.get_session_boundary_mode() == "contact" diff --git a/backend/tests/unit/services/test_session_debounce.py b/backend/tests/unit/services/test_session_debounce.py index 5c8ec463..b3d28611 100644 --- a/backend/tests/unit/services/test_session_debounce.py +++ b/backend/tests/unit/services/test_session_debounce.py @@ -1,10 +1,41 @@ """Unit tests for session debounce / grace period logic.""" -from datetime import UTC, datetime, timedelta +import contextlib +from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest +from app.models.settings import Setting +from app.services.session_service import SessionService +from app.utils.datetime_utils import utc_now + +#: A fixed past instant. Relative-to-now session fixtures are calendar bombs. +FIXED_T0 = datetime(2026, 9, 1, 8, 0, 0) + + +async def _run_finalizer(db_session) -> None: + """Run `finalize_pending_offlines` against the TEST session. + + The task opens its own `AsyncSessionLocal`, so the factory is patched to + hand back the test session instead. `contextlib.nullcontext` keeps the + session alive afterwards: the real factory closes it on exit, and closing + the shared test session would take every later assertion with it. + """ + from app.tasks import livelink_tasks + + existing = await db_session.get(Setting, "livelink_enabled") + if existing is None: + db_session.add(Setting(key="livelink_enabled", value="true")) + else: + existing.value = "true" + await db_session.flush() + + with patch.object( + livelink_tasks, "AsyncSessionLocal", lambda: contextlib.nullcontext(db_session) + ): + await livelink_tasks.finalize_pending_offlines() + def _mock_device( device_id: str = "aabbccddeeff", @@ -72,184 +103,149 @@ async def test_online_clears_pending(self): class TestFinalizePendingOfflines: - """Test the background task that finalizes pending offlines.""" + """The grace-period finalizer, asserted against ROWS rather than mocks. + + These were mock-only: they patched `SessionService` wholesale and asserted + that `handle_ecu_offline` had been called. That call was a **no-op**, and + had been since the grace period was introduced. The ingest routes persist + `ecu_status='offline'` the instant it arrives, so by the time the finalizer + runs `handle_ecu_status_change` sees offline -> offline and returns None + without touching the session. The tests passed anyway, because "was this + method called?" is not "did the session close?". + + The session did eventually close, via a contact timeout -- anchored on a + `last_seen` that the finalizer itself had advanced by calling + `update_device_status`, so the drive's tail was padded by the whole grace + period plus however long the timeout took to notice. + + So each test here reads `drive_sessions.ended_at`, and the two properties + that the mocked versions could not see -- that the session closes, and that + `last_seen` is left alone -- get one test each. + """ @pytest.mark.asyncio - async def test_finalizes_after_grace_period(self): - """Devices past grace period should be finalized.""" - from app.tasks.livelink_tasks import finalize_pending_offlines - - # Device pending since 120 seconds ago (grace = 60) - device = _mock_device( - pending_offline_at=datetime.now(UTC) - timedelta(seconds=120), + async def test_the_session_actually_closes(self, db_session, make_livelink_vehicle): + + vin, device = await make_livelink_vehicle("finoff", "1") + service = SessionService(db_session) + moved_at = FIXED_T0 + timedelta(minutes=1) + device.last_seen = moved_at + for at in (FIXED_T0, moved_at): + await service.observe_telemetry(device, {"SPEED": 44.0}, at, live=True) + await db_session.flush() + session_id = device.current_session_id + assert session_id is not None, "the fixture must open a session, or this proves nothing" + + device.pending_offline_at = utc_now().replace(tzinfo=None) - timedelta(seconds=120) + await db_session.flush() + + await _run_finalizer(db_session) + + closed = await service.get_session(session_id) + assert closed is not None + assert closed.ended_at == moved_at, ( + "the session must close at the last MOVEMENT, not at last contact" ) - mock_livelink = AsyncMock() - mock_livelink.is_enabled = AsyncMock(return_value=True) - mock_livelink.get_session_grace_period_seconds = AsyncMock(return_value=60) - mock_livelink.get_devices_pending_offline = AsyncMock(return_value=[device]) - mock_livelink.clear_pending_offline = AsyncMock() - mock_livelink.update_device_status = AsyncMock() - - mock_session = AsyncMock() - - with ( - patch("app.tasks.livelink_tasks.AsyncSessionLocal") as mock_db_factory, - patch("app.tasks.livelink_tasks.LiveLinkService", return_value=mock_livelink), - patch("app.tasks.livelink_tasks.SessionService", return_value=mock_session), - ): - mock_db = AsyncMock() - mock_db_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db) - mock_db_factory.return_value.__aexit__ = AsyncMock(return_value=None) - - await finalize_pending_offlines() - - # Should have called handle_ecu_offline and cleared pending - mock_session.handle_ecu_offline.assert_called_once_with( - device.vin, - device.device_id, - ) - mock_livelink.clear_pending_offline.assert_called_once_with(device.device_id) - mock_livelink.update_device_status.assert_called_once() - @pytest.mark.asyncio - async def test_does_not_finalize_within_grace(self): - """Devices within grace period should NOT be finalized.""" - from app.tasks.livelink_tasks import finalize_pending_offlines + async def test_it_does_not_advance_last_seen(self, db_session, make_livelink_vehicle): + """There was no contact. Fabricating one corrupts every timeout.""" - # Device pending since 10 seconds ago (grace = 60) - device = _mock_device( - pending_offline_at=datetime.now(UTC) - timedelta(seconds=10), - ) + vin, device = await make_livelink_vehicle("finoff", "2") + device.last_seen = FIXED_T0 + device.pending_offline_at = utc_now().replace(tzinfo=None) - timedelta(seconds=120) + await db_session.flush() - mock_livelink = AsyncMock() - mock_livelink.is_enabled = AsyncMock(return_value=True) - mock_livelink.get_session_grace_period_seconds = AsyncMock(return_value=60) - mock_livelink.get_devices_pending_offline = AsyncMock(return_value=[device]) - mock_livelink.clear_pending_offline = AsyncMock() + await _run_finalizer(db_session) + await db_session.refresh(device) - mock_session = AsyncMock() - - with ( - patch("app.tasks.livelink_tasks.AsyncSessionLocal") as mock_db_factory, - patch("app.tasks.livelink_tasks.LiveLinkService", return_value=mock_livelink), - patch("app.tasks.livelink_tasks.SessionService", return_value=mock_session), - ): - mock_db = AsyncMock() - mock_db_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db) - mock_db_factory.return_value.__aexit__ = AsyncMock(return_value=None) - - await finalize_pending_offlines() - - # Should NOT have called handle_ecu_offline - mock_session.handle_ecu_offline.assert_not_called() - mock_livelink.clear_pending_offline.assert_not_called() + assert device.last_seen == FIXED_T0 @pytest.mark.asyncio - async def test_grace_zero_skips_finalization(self): - """Grace period of 0 should skip finalization entirely.""" - from app.tasks.livelink_tasks import finalize_pending_offlines + async def test_it_clears_the_pending_movement_state(self, db_session, make_livelink_vehicle): + """Pending clears when the offline FINALIZES, not when it arrives. - mock_livelink = AsyncMock() - mock_livelink.is_enabled = AsyncMock(return_value=True) - mock_livelink.get_session_grace_period_seconds = AsyncMock(return_value=0) + `test_a_dropout_inside_the_grace_keeps_the_pending_envelope` in + `test_session_state_machine.py` is the other half of this pair. + """ + vin, device = await make_livelink_vehicle("finoff", "3") + service = SessionService(db_session) + await service.observe_telemetry(device, {"ENGINE_RPM": 700.0}, FIXED_T0, live=True) + await db_session.flush() + assert device.pending_since == FIXED_T0 - with ( - patch("app.tasks.livelink_tasks.AsyncSessionLocal") as mock_db_factory, - patch("app.tasks.livelink_tasks.LiveLinkService", return_value=mock_livelink), - ): - mock_db = AsyncMock() - mock_db_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db) - mock_db_factory.return_value.__aexit__ = AsyncMock(return_value=None) + device.pending_offline_at = utc_now().replace(tzinfo=None) - timedelta(seconds=120) + await db_session.flush() - await finalize_pending_offlines() + await _run_finalizer(db_session) + await db_session.refresh(device) - # Should NOT have queried for pending devices - mock_livelink.get_devices_pending_offline.assert_not_called() + assert device.pending_since is None + assert device.pending_offline_at is None @pytest.mark.asyncio - async def test_no_pending_devices_noop(self): - """No pending devices should be a no-op.""" - from app.tasks.livelink_tasks import finalize_pending_offlines - - mock_livelink = AsyncMock() - mock_livelink.is_enabled = AsyncMock(return_value=True) - mock_livelink.get_session_grace_period_seconds = AsyncMock(return_value=60) - mock_livelink.get_devices_pending_offline = AsyncMock(return_value=[]) + async def test_it_marks_the_device_offline(self, db_session, make_livelink_vehicle): + vin, device = await make_livelink_vehicle("finoff", "4") + device.device_status = "online" + device.pending_offline_at = utc_now().replace(tzinfo=None) - timedelta(seconds=120) + await db_session.flush() - with ( - patch("app.tasks.livelink_tasks.AsyncSessionLocal") as mock_db_factory, - patch("app.tasks.livelink_tasks.LiveLinkService", return_value=mock_livelink), - ): - mock_db = AsyncMock() - mock_db_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db) - mock_db_factory.return_value.__aexit__ = AsyncMock(return_value=None) - - await finalize_pending_offlines() + await _run_finalizer(db_session) + await db_session.refresh(device) - # No commits should have happened - mock_db.commit.assert_not_called() + assert device.device_status == "offline" @pytest.mark.asyncio - async def test_unlinked_device_clears_pending_without_session(self): - """Unlinked device (no VIN) should clear pending without session transition.""" - from app.tasks.livelink_tasks import finalize_pending_offlines - - device = _mock_device( - vin=None, - pending_offline_at=datetime.now(UTC) - timedelta(seconds=120), - ) + async def test_a_device_inside_its_grace_is_left_alone(self, db_session, make_livelink_vehicle): + """The WiFi-drop case the grace period exists for.""" + vin, device = await make_livelink_vehicle("finoff", "5") + service = SessionService(db_session) + moved_at = FIXED_T0 + timedelta(minutes=1) + device.last_seen = moved_at + for at in (FIXED_T0, moved_at): + await service.observe_telemetry(device, {"SPEED": 44.0}, at, live=True) + await db_session.flush() + session_id = device.current_session_id + + device.pending_offline_at = utc_now().replace(tzinfo=None) - timedelta(seconds=10) + await db_session.flush() + + await _run_finalizer(db_session) + + still_open = await service.get_session(session_id) + assert still_open is not None + assert still_open.ended_at is None + assert device.pending_offline_at is not None - mock_livelink = AsyncMock() - mock_livelink.is_enabled = AsyncMock(return_value=True) - mock_livelink.get_session_grace_period_seconds = AsyncMock(return_value=60) - mock_livelink.get_devices_pending_offline = AsyncMock(return_value=[device]) - mock_livelink.clear_pending_offline = AsyncMock() - mock_livelink.update_device_status = AsyncMock() - - mock_session = AsyncMock() - - with ( - patch("app.tasks.livelink_tasks.AsyncSessionLocal") as mock_db_factory, - patch("app.tasks.livelink_tasks.LiveLinkService", return_value=mock_livelink), - patch("app.tasks.livelink_tasks.SessionService", return_value=mock_session), - ): - mock_db = AsyncMock() - mock_db_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db) - mock_db_factory.return_value.__aexit__ = AsyncMock(return_value=None) - - await finalize_pending_offlines() - - # Should NOT call session service (no VIN), but should clear pending - mock_session.handle_ecu_offline.assert_not_called() - mock_livelink.clear_pending_offline.assert_called_once_with(device.device_id) - - -class TestNaiveTimestampHandling: - """Ensure naive timestamps from DB are handled correctly.""" + @pytest.mark.asyncio + async def test_an_unlinked_device_still_clears_its_pending_state( + self, db_session, make_livelink_vehicle + ): + """No VIN means no session to close, but the state must still be reset, + or the device stays pending-offline forever and the finalizer revisits + it on every fifteen-second tick.""" + vin, device = await make_livelink_vehicle("finoff", "6") + device.vin = None + device.pending_offline_at = utc_now().replace(tzinfo=None) - timedelta(seconds=120) + await db_session.flush() + + await _run_finalizer(db_session) + await db_session.refresh(device) + + assert device.pending_offline_at is None @pytest.mark.asyncio - async def test_naive_pending_offline_at(self): - """Naive (no tzinfo) pending_offline_at should be handled.""" + async def test_grace_zero_skips_finalization(self): + """Grace period of 0 should skip finalization entirely.""" from app.tasks.livelink_tasks import finalize_pending_offlines - # Simulate naive datetime from SQLite (no timezone) - naive_time = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=120) - device = _mock_device(pending_offline_at=naive_time) - mock_livelink = AsyncMock() mock_livelink.is_enabled = AsyncMock(return_value=True) - mock_livelink.get_session_grace_period_seconds = AsyncMock(return_value=60) - mock_livelink.get_devices_pending_offline = AsyncMock(return_value=[device]) - mock_livelink.clear_pending_offline = AsyncMock() - mock_livelink.update_device_status = AsyncMock() - - mock_session = AsyncMock() + mock_livelink.get_session_grace_period_seconds = AsyncMock(return_value=0) with ( patch("app.tasks.livelink_tasks.AsyncSessionLocal") as mock_db_factory, patch("app.tasks.livelink_tasks.LiveLinkService", return_value=mock_livelink), - patch("app.tasks.livelink_tasks.SessionService", return_value=mock_session), ): mock_db = AsyncMock() mock_db_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db) @@ -257,6 +253,4 @@ async def test_naive_pending_offline_at(self): await finalize_pending_offlines() - # Should finalize without crashing - mock_session.handle_ecu_offline.assert_called_once() - mock_livelink.clear_pending_offline.assert_called_once() + mock_livelink.get_devices_pending_offline.assert_not_called() diff --git a/backend/tests/unit/services/test_session_distance_source.py b/backend/tests/unit/services/test_session_distance_source.py new file mode 100644 index 00000000..efe52eda --- /dev/null +++ b/backend/tests/unit/services/test_session_distance_source.py @@ -0,0 +1,268 @@ +"""A session's distance comes from the finest distance signal in its window. + +`_calculate_session_distance` read odometer keys and nothing else, which is +correct on hardware whose odometer resolves finely and useless on hardware whose +odometer steps further than a typical trip. The measurements, and the argument +for which keys qualify, live in `app/utils/distance_counters.py`; the selection +arithmetic is unit-tested without a database in +`tests/unit/utils/test_distance_counters.py`. + +What is left to test HERE is the part that needs rows: that the winning source +reaches `distance_km`, that the odometer alone reaches `start_odometer` / +`end_odometer`, and that the session window still bounds every source rather +than only the odometer. +""" + +from datetime import timedelta + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.livelink_device import LiveLinkDevice +from app.models.vehicle_telemetry import VehicleTelemetry +from app.services.session_service import SessionService +from app.utils.datetime_utils import utc_now + + +@pytest_asyncio.fixture +async def make_vehicle(make_livelink_vehicle): + """Async factory: (suffix) -> (vin, device).""" + + async def _factory(suffix: str) -> tuple[str, LiveLinkDevice]: + return await make_livelink_vehicle("dsrc", suffix) + + return _factory + + +@pytest_asyncio.fixture +async def seed(db_session: AsyncSession): + """Async factory: (vin, device_id, param_key, [(offset_s, value)], anchor) -> None.""" + + async def _factory( + vin: str, + device_id: str, + param_key: str, + samples: list[tuple[int, float]], + anchor, + ) -> None: + for offset, value in samples: + db_session.add( + VehicleTelemetry( + vin=vin, + device_id=device_id, + param_key=param_key, + value=value, + timestamp=anchor + timedelta(seconds=offset), + received_at=anchor, + ) + ) + await db_session.flush() + + return _factory + + +async def _run_session(db_session, device, anchor, seconds: int = 900): + """Open and close a session over the window, returning the closed row.""" + service = SessionService(db_session) + await service.start_session(device, anchor) + await db_session.flush() + session = await service.end_session(device, anchor + timedelta(seconds=seconds)) + await db_session.flush() + return session + + +@pytest.mark.asyncio +class TestDistanceSourceSelection: + async def test_a_finer_counter_beats_a_flat_odometer(self, db_session, make_vehicle, seed): + """The Mirage's real shape: the odometer never ticks, the counter does. + + A 12 km drive that the odometer cannot see at all, because its step is + twice the length of the trip. Reading only the odometer records zero. + """ + vin, device = await make_vehicle("1") + anchor = utc_now().replace(tzinfo=None) - timedelta(hours=1) + + await seed(vin, device.device_id, "ODOMETER", [(30, 141300.0), (600, 141300.0)], anchor) + await seed( + vin, + device.device_id, + "31-DISTANCESINCECODECLEAR", + [(30, 500.0), (200, 504.0), (400, 509.0), (600, 512.0)], + anchor, + ) + + session = await _run_session(db_session, device, anchor) + + assert session is not None + assert session.distance_km == pytest.approx(12.0) + + async def test_a_counter_never_stamps_the_odometer_columns( + self, db_session, make_vehicle, seed + ): + """Distance from the counter, odometer columns untouched. + + PID 0x31 counts from the last code clear, so its value is not mileage. + Writing 512 into `start_odometer` would report a vehicle with 512 km on + it, which is a worse lie than a missing number. + """ + vin, device = await make_vehicle("2") + anchor = utc_now().replace(tzinfo=None) - timedelta(hours=1) + + await seed( + vin, + device.device_id, + "31-DISTANCESINCECODECLEAR", + [(30, 500.0), (600, 512.0)], + anchor, + ) + + session = await _run_session(db_session, device, anchor) + + assert session is not None + assert session.distance_km == pytest.approx(12.0) + assert session.start_odometer is None + assert session.end_odometer is None + + async def test_the_odometer_still_supplies_both_when_it_wins( + self, db_session, make_vehicle, seed + ): + """A device whose odometer resolves finely is completely unaffected. + + The Ram's shape: `A6-ODOMETER` steps about every 2 km, so it out-resolves + anything else in the window and keeps all three fields. + """ + vin, device = await make_vehicle("3") + anchor = utc_now().replace(tzinfo=None) - timedelta(hours=1) + + await seed( + vin, + device.device_id, + "A6-ODOMETER", + [(30, 9195.0), (200, 9197.0), (400, 9203.0), (600, 9210.0)], + anchor, + ) + await seed( + vin, + device.device_id, + "31-DISTANCESINCECODECLEAR", + [(30, 100.0), (600, 122.0)], + anchor, + ) + + session = await _run_session(db_session, device, anchor) + + assert session is not None + assert session.distance_km == pytest.approx(15.0), ( + "the odometer's 15, not the coarser counter's 22 -- the two are " + "deliberately different so this assertion can tell them apart" + ) + assert session.start_odometer == pytest.approx(9195.0) + assert session.end_odometer == pytest.approx(9210.0) + + async def test_an_equal_resolution_tie_goes_to_the_odometer( + self, db_session, make_vehicle, seed + ): + """Displacing the odometer requires resolving STRICTLY finer. + + Both sources step twice here. The odometer keeps the job, so this change + can only ever add distance to sessions that had none, never quietly + restate sessions that were already being measured. + """ + vin, device = await make_vehicle("4") + anchor = utc_now().replace(tzinfo=None) - timedelta(hours=1) + + await seed( + vin, + device.device_id, + "A6-ODOMETER", + [(30, 9195.0), (300, 9200.0), (600, 9205.0)], + anchor, + ) + await seed( + vin, + device.device_id, + "31-DISTANCESINCECODECLEAR", + [(30, 100.0), (300, 108.0), (600, 118.0)], + anchor, + ) + + session = await _run_session(db_session, device, anchor) + + assert session is not None + assert session.distance_km == pytest.approx(10.0), ( + "the odometer's span, not the counter's 18" + ) + + async def test_a_reset_inside_the_window_sums_only_the_rises( + self, db_session, make_vehicle, seed + ): + """A code clear mid-window must not become 800 km of driving. + + `max - min` over these samples is 806. The vehicle drove 15. + """ + vin, device = await make_vehicle("5") + anchor = utc_now().replace(tzinfo=None) - timedelta(hours=1) + + await seed( + vin, + device.device_id, + "31-DISTANCESINCECODECLEAR", + [(30, 800.0), (200, 810.0), (400, 4.0), (600, 9.0)], + anchor, + ) + + session = await _run_session(db_session, device, anchor) + + assert session is not None + assert session.distance_km == pytest.approx(15.0) + + async def test_an_unprefixed_counter_is_not_a_distance_source( + self, db_session, make_vehicle, seed + ): + """A bare autopid of the same name reports the dash, in unknown units. + + Nothing declares units for a distance counter the way + `LiveLinkDevice.odometer_unit` does for an odometer, so this contributes + no distance rather than 12 possibly-mile kilometres. + """ + vin, device = await make_vehicle("6") + anchor = utc_now().replace(tzinfo=None) - timedelta(hours=1) + + await seed( + vin, + device.device_id, + "DISTANCESINCECODECLEAR", + [(30, 500.0), (600, 512.0)], + anchor, + ) + + session = await _run_session(db_session, device, anchor) + + assert session is not None + assert not session.distance_km, "an unreadable source contributes nothing" + + async def test_samples_outside_the_window_are_still_excluded( + self, db_session, make_vehicle, seed + ): + """The window remains the arbiter for the counter, exactly as for the odometer. + + Driving that happened while no session was open belongs to no session. + The counter must not reopen the wider-lookup bug the odometer path was + fixed for. + """ + vin, device = await make_vehicle("7") + anchor = utc_now().replace(tzinfo=None) - timedelta(hours=1) + + await seed( + vin, + device.device_id, + "31-DISTANCESINCECODECLEAR", + [(-3600, 400.0), (30, 500.0), (600, 512.0), (5400, 900.0)], + anchor, + ) + + session = await _run_session(db_session, device, anchor) + + assert session is not None + assert session.distance_km == pytest.approx(12.0) diff --git a/backend/tests/unit/services/test_session_ingest_sites.py b/backend/tests/unit/services/test_session_ingest_sites.py new file mode 100644 index 00000000..2d198f81 --- /dev/null +++ b/backend/tests/unit/services/test_session_ingest_sites.py @@ -0,0 +1,272 @@ +"""No live ingest path opens a session without movement. All three of them. + +The count is the point. An earlier revision of this design named +``mqtt_subscriber.py:440`` as "the root cause, in code" and fixed that one path. +Enumerating **every** path that can construct a ``DriveSession``, rather than +the one the design expected to find, there are five: + +=================================== ========================== ============== +path trigger in scope +=================================== ========================== ============== +``mqtt_subscriber`` ``can/rx`` any telemetry at all yes +``mqtt_subscriber`` ``can/status`` explicit ``ecu_status`` yes +``routes/livelink`` ``/ingest`` explicit status block yes +``routes/torque`` phone-supplied session id no, by design +``bulk_backfill`` (SD card) replayed SD rows yes, separately +=================================== ========================== ============== + +The comment directly above the path that first revision fixed says it "handles +WiCAN devices that don't send explicit can/status messages" -- so by the code's +own account it is the FALLBACK. An instance whose dongle sends status messages, +or any instance on HTTPS ingest, would have kept every one of its phantom +sessions while the changelog said they were fixed. + +**Where a battery-only test must be seeded.** The dedicated MQTT ``battery`` +subtopic never called ``SessionService`` at all and was already correct, so a +battery-only assertion seeded there passes trivially and proves nothing. It has +to go on ``can/rx``, which is the path that inferred ECU-online from *any* +telemetry. + +Mutation check for this file: restore the unconditional ``handle_ecu_online`` +call in ``SessionService.handle_ecu_status_change``'s online branch, and every +``opens_nothing`` test here must fail. Recorded in the commit message. +""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.drive_session import DriveSession +from app.models.settings import Setting +from app.services.mqtt_subscriber import MQTTSubscriber +from app.services.session_service import SessionService + +pytestmark = pytest.mark.asyncio + +T0 = datetime(2026, 9, 1, 8, 0, 0) + + +async def _sessions(db: AsyncSession, device_id: str) -> list[DriveSession]: + return list( + (await db.execute(select(DriveSession).where(DriveSession.device_id == device_id))) + .scalars() + .all() + ) + + +async def _enable_livelink(db: AsyncSession) -> None: + existing = ( + await db.execute(select(Setting).where(Setting.key == "livelink_enabled")) + ).scalar_one_or_none() + if existing is None: + db.add(Setting(key="livelink_enabled", value="true")) + else: + existing.value = "true" + await db.flush() + + +class TestMqttCanRx: + """The telemetry-inferred path: the one the first design revision fixed.""" + + async def test_a_battery_only_payload_opens_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The exact payload behind 2,975 of this instance's 3,238 sessions. + + Seeded on `can/rx`, NOT on the `battery` subtopic: that subtopic never + touched SessionService, so the same assertion there would pass against + completely unfixed code. + """ + vin, device = await make_livelink_vehicle("ingrx", "1") + await _enable_livelink(db_session) + + await MQTTSubscriber()._handle_telemetry( + db_session, device.device_id, {"BATTERY_VOLTAGE": 12.4} + ) + await db_session.flush() + + assert await _sessions(db_session, device.device_id) == [] + + async def test_an_idling_payload_opens_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Engine on, stationary. The eleven-minute driveway idle.""" + vin, device = await make_livelink_vehicle("ingrx", "2") + await _enable_livelink(db_session) + + subscriber = MQTTSubscriber() + for _ in range(3): + await subscriber._handle_telemetry( + db_session, device.device_id, {"ENGINE_RPM": 760, "SPEED": 0} + ) + await db_session.flush() + + assert await _sessions(db_session, device.device_id) == [] + + async def test_a_moving_payload_does_open_one( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The positive control. + + Without it, every assertion above is satisfied by an ingest path that + has stopped creating sessions under any circumstances -- which is a far + worse bug than the one being fixed, and would look identical here. + """ + vin, device = await make_livelink_vehicle("ingrx", "3") + await _enable_livelink(db_session) + + subscriber = MQTTSubscriber() + for _ in range(2): + await subscriber._handle_telemetry( + db_session, device.device_id, {"SPEED": 52, "ENGINE_RPM": 2200} + ) + await db_session.flush() + + sessions = await _sessions(db_session, device.device_id) + assert len(sessions) == 1 + assert sessions[0].boundary_algorithm_version == 1 + + +class TestMqttCanStatus: + """The explicit-status path, which the first revision did not reach.""" + + async def test_an_explicit_ecu_online_opens_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The payload key is `status`, not `ecu_status`. + + Worth a sentence, because writing it wrong is not a failing test: an + unrecognised value maps to `"unknown"`, and the handler skips session + handling entirely for unknown. So the mis-keyed version of this test + passed against completely unfixed code, and only the paired + `test_an_ecu_online_signal_still_marks_the_device_online` below caught + it. Both assertions have to be here for either to mean anything. + """ + vin, device = await make_livelink_vehicle("ingst", "1") + await _enable_livelink(db_session) + + await MQTTSubscriber()._handle_status(db_session, device.device_id, {"status": "online"}) + await db_session.flush() + await db_session.refresh(device) + + assert await _sessions(db_session, device.device_id) == [] + assert device.ecu_status == "online", ( + "guard on the guard: a payload the handler treats as `unknown` " + "skips session handling, so the assertion above would pass unfixed" + ) + + async def test_an_ecu_online_signal_still_marks_the_device_online( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """It marks the device online and nothing more -- but it must still do + that. `device_command_service` refuses any `requires_ecu` command when + `ecu_status != "online"`, so losing this would take the remote-command + surface down with it.""" + vin, device = await make_livelink_vehicle("ingst", "2") + await _enable_livelink(db_session) + + await MQTTSubscriber()._handle_status(db_session, device.device_id, {"status": "online"}) + await db_session.flush() + await db_session.refresh(device) + + assert device.ecu_status == "online" + + +class TestHttpsIngest: + """The third live site, driven through the real route.""" + + async def test_a_status_block_alone_opens_nothing( + self, db_session: AsyncSession, client: AsyncClient, make_livelink_vehicle + ): + vin, device = await make_livelink_vehicle("inghttp", "1") + await _enable_livelink(db_session) + + with patch( + "app.routes.livelink.validate_livelink_token", new_callable=AsyncMock + ) as validate: + validate.return_value = True + response = await client.post( + "/api/v1/livelink/ingest", + json={ + "autopid_data": {"BATTERY_VOLTAGE": 12.4}, + "config": {}, + "status": {"device_id": device.device_id, "ecu_status": "online"}, + }, + headers={"Authorization": "Bearer t"}, + ) + + assert response.status_code == 202 + assert await _sessions(db_session, device.device_id) == [] + + async def test_movement_over_https_does_open_one( + self, db_session: AsyncSession, client: AsyncClient, make_livelink_vehicle + ): + vin, device = await make_livelink_vehicle("inghttp", "2") + await _enable_livelink(db_session) + + with patch( + "app.routes.livelink.validate_livelink_token", new_callable=AsyncMock + ) as validate: + validate.return_value = True + for _ in range(2): + response = await client.post( + "/api/v1/livelink/ingest", + json={ + "autopid_data": {"SPEED": 48, "ENGINE_RPM": 2100}, + "config": {}, + "status": {"device_id": device.device_id, "ecu_status": "online"}, + }, + headers={"Authorization": "Bearer t"}, + ) + assert response.status_code == 202 + + assert len(await _sessions(db_session, device.device_id)) == 1 + + +class TestTorqueIsUntouched: + """C8: Torque's behaviour is byte-identical before and after. + + ``resolve_torque_session`` is a second constructor on a separate contract: + the phone has already decided where the drive begins and supplies its id, so + a movement predicate has nothing to add and would only overrule a better + source. Any pass over history excludes Torque by ``external_session_id`` + rather than by heuristic, for the same reason. + """ + + async def test_a_torque_upload_still_opens_a_session_immediately( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """One payload, no debounce, no movement signal at all -- because the + phone's session id IS the evidence.""" + vin, device = await make_livelink_vehicle("ingtq", "1", kind="torque") + service = SessionService(db_session) + + session = await service.resolve_torque_session(device, "torque-session-1", T0) + await db_session.flush() + + assert session is not None + assert session.external_session_id == "torque-session-1" + + async def test_a_torque_session_is_stamped_as_not_this_algorithm( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Version 0, because its boundaries come from the phone. + + Stamping it 1 would claim a provenance it does not have, and it is also + how any later pass over history knows to leave it alone. + """ + vin, device = await make_livelink_vehicle("ingtq", "2", kind="torque") + service = SessionService(db_session) + + session = await service.resolve_torque_session(device, "torque-session-2", T0) + await db_session.flush() + + assert session is not None + assert session.boundary_algorithm_version == 0 + assert session.effective_gap_minutes is None diff --git a/backend/tests/unit/services/test_session_state_machine.py b/backend/tests/unit/services/test_session_state_machine.py new file mode 100644 index 00000000..313d4c5d --- /dev/null +++ b/backend/tests/unit/services/test_session_state_machine.py @@ -0,0 +1,979 @@ +"""C9b's state machine, one test per cell, against real rows. + +The transitions were originally described in prose spread across four decisions, +and review found four sequences the prose did not answer: a 20-minute connected +stop, a 6-minute silent gap, an explicit ECU-offline inside its grace period, +and a pending drive that survives a dropout. Prose across four sections cannot +be checked for completeness, so the design collapsed it into one table and this +file walks it. + +States, per device: ``idle``, ``pending`` (engine on, nothing moving yet), +``driving``, ``stopped`` (moved before, connected now, not moving), ``awaiting`` +(closed on contact loss, still reopenable). + +Two things make these tests worth reading rather than skimming: + +**Every assertion has to be false at t=0.** A phantom-session test that passes +because no session was created *for any reason* is not a test -- and this is a +suite where "no session" is the default state, so a mis-seeded test passes +trivially. Each test below either asserts a session exists with specific bounds, +or asserts none exists after seeding a state where the old code created one. + +**The device row is the state.** Not process memory. The MQTT subscriber, the +HTTPS route and the scheduler are three execution contexts; an in-memory +candidate is invisible to two of them and lost on restart, which silently turns +"keep the warm-up samples" into "drop them" every time the container cycles. +``test_a_pending_drive_survives_a_process_restart`` is that claim, tested by +re-reading the row through a fresh service instance. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.drive_session import DriveSession +from app.models.vehicle_telemetry import VehicleTelemetry +from app.services.session_service import SessionService + +pytestmark = pytest.mark.asyncio + +GAP = 15 +TIMEOUT = 5 + +#: A fixed, past base time. Relative-to-now seeding is a calendar bomb: one such +#: test failed the v3.0.0 publish because a "30 days ago" fixture crossed a +#: month boundary. +T0 = datetime(2026, 9, 1, 8, 0, 0) + +BATTERY = {"BATTERY_VOLTAGE": 12.4} +IDLING = {"ENGINE_RPM": 780.0, "SPEED": 0.0} +MOVING = {"SPEED": 48.0, "ENGINE_RPM": 2100.0} +EV_MOVING = {"SPEED": 48.0} +STOPPED_CONNECTED = {"SPEED": 0.0, "ENGINE_RPM": 750.0} + + +async def _open_session(db: AsyncSession, device_id: str) -> DriveSession | None: + return ( + await db.execute( + select(DriveSession) + .where(DriveSession.device_id == device_id) + .where(DriveSession.ended_at.is_(None)) + ) + ).scalar_one_or_none() + + +async def _all_sessions(db: AsyncSession, device_id: str) -> list[DriveSession]: + return list( + ( + await db.execute( + select(DriveSession) + .where(DriveSession.device_id == device_id) + .order_by(DriveSession.started_at) + ) + ) + .scalars() + .all() + ) + + +async def _seed_telemetry( + db: AsyncSession, vin: str, device_id: str, at: datetime, samples: dict +) -> None: + """Persist a batch, so window-scanning aggregates have something to find.""" + for key, value in samples.items(): + db.add( + VehicleTelemetry( + vin=vin, + device_id=device_id, + param_key=key, + value=float(value), + timestamp=at, + ) + ) + await db.flush() + + +async def _drive( + service: SessionService, + db: AsyncSession, + vin: str, + device, + batches: list[tuple[datetime, dict]], + *, + live: bool = True, + persist: bool = False, +) -> DriveSession | None: + """Feed batches in order, mirroring what an ingest path does per payload.""" + result = None + for at, samples in batches: + if persist: + await _seed_telemetry(db, vin, device.device_id, at, samples) + device.last_seen = at + result = await service.observe_telemetry(device, samples, at, live=live) + await db.flush() + return result + + +# --------------------------------------------------------------------------- +# idle +# --------------------------------------------------------------------------- + + +class TestFromIdle: + async def test_a_battery_heartbeat_opens_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The payload that produced 2,975 of this instance's 3,238 sessions. + + Under the old rule any telemetry inferred ECU-online and opened a + session, so a parked vehicle recorded a drive roughly every 95 minutes. + """ + vin, device = await make_livelink_vehicle("smidle", "1") + service = SessionService(db_session) + + await _drive(service, db_session, vin, device, [(T0, BATTERY)]) + + assert await _open_session(db_session, device.device_id) is None + assert await _all_sessions(db_session, device.device_id) == [] + + async def test_two_heartbeats_ninety_five_minutes_apart_open_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The measured heartbeat interval, which is also the phantom period. + + Seeded at the real cadence rather than back-to-back, because a debounce + keyed on "consecutive samples" without a time bound would pair these two + and call them movement. + """ + vin, device = await make_livelink_vehicle("smidle", "2") + service = SessionService(db_session) + + await _drive( + service, + db_session, + vin, + device, + [(T0, BATTERY), (T0 + timedelta(minutes=95), BATTERY)], + ) + + assert await _all_sessions(db_session, device.device_id) == [] + + async def test_engine_on_becomes_pending_and_not_a_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """RPM above zero is engine-on, which is not a drive. + + Remote start, a diagnostic session, a winter warm-up, or the + eleven-minute driveway idle that was credited with 14 km. + """ + vin, device = await make_livelink_vehicle("smidle", "3") + service = SessionService(db_session) + + await _drive(service, db_session, vin, device, [(T0, IDLING)]) + + assert await _all_sessions(db_session, device.device_id) == [] + assert device.pending_since == T0 + assert device.pending_source == "rpm" + + async def test_a_long_idle_never_becomes_a_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Eleven minutes of idling, at the sampling cadence, still zero drives.""" + vin, device = await make_livelink_vehicle("smidle", "4") + service = SessionService(db_session) + + batches = [(T0 + timedelta(minutes=m), IDLING) for m in range(12)] + await _drive(service, db_session, vin, device, batches) + + assert await _all_sessions(db_session, device.device_id) == [] + + +# --------------------------------------------------------------------------- +# The floor and the debounce +# --------------------------------------------------------------------------- + + +class TestTheFloorAndDebounce: + async def test_one_sample_above_the_floor_opens_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """A single noisy sample is effectively unvalidatable. + + `validate_rate_of_change` skips entirely when the previous reading is + older than 120 seconds, which is exactly the parked-heartbeat case, so + nothing upstream would have caught a spurious 48 km/h either. + """ + vin, device = await make_livelink_vehicle("smfloor", "1") + service = SessionService(db_session) + + await _drive(service, db_session, vin, device, [(T0, MOVING)]) + + assert await _all_sessions(db_session, device.device_id) == [] + assert device.movement_candidate_at == T0, "the sample is remembered as a candidate" + + async def test_two_consecutive_samples_above_the_floor_open_a_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + vin, device = await make_livelink_vehicle("smfloor", "2") + service = SessionService(db_session) + + await _drive( + service, + db_session, + vin, + device, + [(T0, MOVING), (T0 + timedelta(seconds=30), MOVING)], + ) + + session = await _open_session(db_session, device.device_id) + assert session is not None + assert session.boundary_algorithm_version == 1 + assert session.effective_gap_minutes == GAP + + @pytest.mark.parametrize("speed", [1.0, 4.9]) + async def test_two_samples_below_the_floor_open_nothing( + self, db_session: AsyncSession, make_livelink_vehicle, speed: float + ): + vin, device = await make_livelink_vehicle("smfloor", f"3{int(speed * 10)}") + service = SessionService(db_session) + + await _drive( + service, + db_session, + vin, + device, + [(T0, {"SPEED": speed}), (T0 + timedelta(seconds=30), {"SPEED": speed})], + ) + + assert await _all_sessions(db_session, device.device_id) == [] + + async def test_a_debounce_pair_straddling_a_disconnect_is_not_consecutive( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Two samples separated by a dropout are not consecutive in any sense + that matters. A speed spike before a device drops off, matched against + another spike hours later, is precisely the phantom the debounce exists + to suppress -- so the pair must fall inside one gap window.""" + vin, device = await make_livelink_vehicle("smfloor", "4") + service = SessionService(db_session) + + await _drive( + service, + db_session, + vin, + device, + [(T0, MOVING), (T0 + timedelta(minutes=GAP + 1), MOVING)], + ) + + assert await _all_sessions(db_session, device.device_id) == [], ( + "the first spike should have expired as a candidate, not paired" + ) + + +# --------------------------------------------------------------------------- +# The odometer signal +# --------------------------------------------------------------------------- + + +class TestTheOdometerSignal: + async def test_a_rising_odometer_alone_opens_a_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The signal the first design lacked entirely. + + A vehicle whose speed arrives under a name nothing recognises still + proves it moved. Without this, that cohort records no drives at all -- + and SD replay, which requires positive evidence of + movement before touching anything, would then erase its history. + """ + vin, device = await make_livelink_vehicle("smodo", "1") + service = SessionService(db_session) + + await _drive( + service, + db_session, + vin, + device, + [ + (T0, {"A6-ODOMETER": 120_000.0}), + (T0 + timedelta(minutes=8), {"A6-ODOMETER": 120_012.0}), + ], + ) + + assert await _open_session(db_session, device.device_id) is not None + + async def test_an_unchanged_odometer_opens_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """A parked vehicle reports the same odometer on every heartbeat.""" + vin, device = await make_livelink_vehicle("smodo", "2") + service = SessionService(db_session) + + await _drive( + service, + db_session, + vin, + device, + [ + (T0, {"A6-ODOMETER": 120_000.0}), + (T0 + timedelta(minutes=95), {"A6-ODOMETER": 120_000.0}), + ], + ) + + assert await _all_sessions(db_session, device.device_id) == [] + + async def test_a_falling_odometer_opens_nothing( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Out-of-order replay, not a reversing vehicle.""" + vin, device = await make_livelink_vehicle("smodo", "3") + service = SessionService(db_session) + + await _drive( + service, + db_session, + vin, + device, + [ + (T0, {"A6-ODOMETER": 120_050.0}), + (T0 + timedelta(minutes=5), {"A6-ODOMETER": 120_000.0}), + ], + ) + + assert await _all_sessions(db_session, device.device_id) == [] + + async def test_an_ev_with_no_rpm_at_all_gets_sessions( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The test that would have caught the original RPM-only design. + + A stationary EV reports neither speed nor RPM, and a moving one reports + no RPM ever. An RPM-only predicate gives it zero sessions, forever, with + nothing in the log to say why. + """ + vin, device = await make_livelink_vehicle("smev", "1") + service = SessionService(db_session) + + await _drive( + service, + db_session, + vin, + device, + [(T0, EV_MOVING), (T0 + timedelta(minutes=1), EV_MOVING)], + ) + + assert await _open_session(db_session, device.device_id) is not None + + +# --------------------------------------------------------------------------- +# pending +# --------------------------------------------------------------------------- + + +class TestFromPending: + async def test_movement_after_engine_on_opens_a_session_at_the_engine_on( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """C5: the window keeps the whole burst; movement only decides it is a drive. + + Setting `started_at` to the first movement sample silently discards + everything before it, and aggregates are strictly window-bounded -- so + warm-up coolant, initial fuel level and, critically, the OPENING + ODOMETER READING fall outside. `_calculate_session_distance` then finds + exactly one odometer sample in the window and writes + `start_odometer == end_odometer`, i.e. `distance_km = 0.0`: a confident + zero rather than a blank. + """ + vin, device = await make_livelink_vehicle("smpend", "1") + service = SessionService(db_session) + + await _drive( + service, + db_session, + vin, + device, + [ + (T0, IDLING), + (T0 + timedelta(minutes=2), IDLING), + (T0 + timedelta(minutes=3), MOVING), + (T0 + timedelta(minutes=4), MOVING), + ], + ) + + session = await _open_session(db_session, device.device_id) + assert session is not None + assert session.started_at == T0, "the warm-up must be inside the window" + assert session.movement_started_at == T0 + timedelta(minutes=3) + assert device.pending_since is None, "promotion clears the pending state" + + async def test_a_pending_drive_older_than_the_gap_is_discarded( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Without a bound, an engine-on at 08:00 and a movement sample at 17:00 + are still 'consecutive', and the session backdates nine hours of parked + telemetry into a drive.""" + vin, device = await make_livelink_vehicle("smpend", "2") + service = SessionService(db_session) + + much_later = T0 + timedelta(hours=9) + await _drive( + service, + db_session, + vin, + device, + [(T0, IDLING), (much_later, MOVING), (much_later + timedelta(minutes=1), MOVING)], + ) + + session = await _open_session(db_session, device.device_id) + assert session is not None + assert session.started_at >= much_later, ( + f"started_at {session.started_at} backdates into nine hours of parked telemetry" + ) + + async def test_an_expired_pending_drive_never_created_a_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """A warm-up that never went anywhere leaves no trace at all.""" + vin, device = await make_livelink_vehicle("smpend", "3") + service = SessionService(db_session) + + await _drive(service, db_session, vin, device, [(T0, IDLING)]) + await service.expire_stale_movement_state(gap_minutes=GAP, now=T0 + timedelta(minutes=20)) + await db_session.flush() + + assert await _all_sessions(db_session, device.device_id) == [] + assert device.pending_since is None + + async def test_a_pending_drive_survives_a_process_restart( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The claim that the state is durable, not in process memory. + + Three execution contexts observe movement -- the MQTT subscriber, the + HTTPS route and the scheduler. An in-memory candidate is invisible to + two of them and lost on every container cycle, which converts "keep the + warm-up samples" into "drop them" without anything failing. + """ + vin, device = await make_livelink_vehicle("smpend", "4") + + await _drive(SessionService(db_session), db_session, vin, device, [(T0, IDLING)]) + await db_session.commit() + db_session.expunge_all() + + # A completely fresh service and a re-read row, standing in for a restart. + fresh = SessionService(db_session) + reloaded = await fresh._get_device(device.device_id) + assert reloaded is not None + assert reloaded.pending_since == T0 + + await _drive( + fresh, + db_session, + vin, + reloaded, + [(T0 + timedelta(minutes=2), MOVING), (T0 + timedelta(minutes=3), MOVING)], + ) + + session = await _open_session(db_session, reloaded.device_id) + assert session is not None + assert session.started_at == T0, "the pre-restart warm-up is still in the window" + + +# --------------------------------------------------------------------------- +# driving / stopped +# --------------------------------------------------------------------------- + + +class TestDrivingAndStopped: + async def test_continued_movement_extends_one_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + vin, device = await make_livelink_vehicle("smdrive", "1") + service = SessionService(db_session) + + batches = [(T0 + timedelta(minutes=m), MOVING) for m in range(10)] + await _drive(service, db_session, vin, device, batches) + + assert len(await _all_sessions(db_session, device.device_id)) == 1 + assert device.last_movement_at == T0 + timedelta(minutes=9) + + async def test_a_six_minute_connected_stop_does_not_split_the_drive( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The EV-at-a-charger case, and the reason there are two clocks. + + The five-minute setting is a CONNECTION-LOSS detector. Measuring it from + the last movement instead splits a drive whenever a vehicle stops for + six minutes while still connected and still sending stationary + heartbeats: a drive-through, a fuel stop, a school pickup, a drawbridge. + An ICE vehicle keeps RPM through a stop and survives; a stationary EV + reports neither speed nor RPM, so it is exactly the vehicle this breaks. + """ + vin, device = await make_livelink_vehicle("smdrive", "2") + service = SessionService(db_session) + + batches = [(T0, MOVING), (T0 + timedelta(minutes=1), MOVING)] + # Six minutes stopped but still talking. + batches += [(T0 + timedelta(minutes=1 + m), STOPPED_CONNECTED) for m in range(1, 7)] + await _drive(service, db_session, vin, device, batches) + + # The scheduler is what would split it, so run it mid-stop: six minutes + # past the last MOVEMENT but zero seconds past the last CONTACT. + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=T0 + timedelta(minutes=7, seconds=30) + ) + await db_session.flush() + assert await _open_session(db_session, device.device_id) is not None, ( + "the contact-loss clock must not be measured from the last movement" + ) + + await _drive( + service, + db_session, + vin, + device, + [(T0 + timedelta(minutes=8), MOVING), (T0 + timedelta(minutes=9), MOVING)], + ) + + sessions = await _all_sessions(db_session, device.device_id) + assert len(sessions) == 1, "a six-minute stop is one drive, not two" + assert sessions[0].ended_at is None + + async def test_a_twenty_minute_connected_stop_splits_the_drive( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Fifteen minutes stationary is two trips, which is what a person + would call them. The session closes at the last MOVEMENT, not at the + last contact.""" + vin, device = await make_livelink_vehicle("smdrive", "3") + service = SessionService(db_session) + + last_movement = T0 + timedelta(minutes=1) + await _drive(service, db_session, vin, device, [(T0, MOVING), (last_movement, MOVING)]) + + # Still connected, still stationary, for twenty minutes. + for m in range(2, 21): + device.last_seen = T0 + timedelta(minutes=m) + await service.observe_telemetry( + device, STOPPED_CONNECTED, T0 + timedelta(minutes=m), live=True + ) + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=T0 + timedelta(minutes=21) + ) + await db_session.flush() + + first = (await _all_sessions(db_session, device.device_id))[0] + assert first.ended_at == last_movement, ( + f"closed at {first.ended_at}; the contact burst ran to " + f"{T0 + timedelta(minutes=20)} and must not pad the drive" + ) + + async def test_movement_after_the_gap_starts_a_second_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """And its window may NOT backdate into the closed session's window. + + C5's whole-burst rule applies to the OPENING burst of a drive. It cannot + apply to a burst already consumed by a previous session, because every + aggregate is a window scan and two overlapping sessions both claim the + same samples and both report the same distance. + """ + vin, device = await make_livelink_vehicle("smdrive", "4") + service = SessionService(db_session) + + await _drive( + service, db_session, vin, device, [(T0, MOVING), (T0 + timedelta(minutes=1), MOVING)] + ) + for m in range(2, 21): + device.last_seen = T0 + timedelta(minutes=m) + await service.observe_telemetry( + device, STOPPED_CONNECTED, T0 + timedelta(minutes=m), live=True + ) + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=T0 + timedelta(minutes=21) + ) + await db_session.flush() + + resumed = T0 + timedelta(minutes=22) + await _drive( + service, + db_session, + vin, + device, + [(resumed, MOVING), (resumed + timedelta(minutes=1), MOVING)], + ) + + sessions = await _all_sessions(db_session, device.device_id) + assert len(sessions) == 2 + assert sessions[1].started_at >= sessions[0].ended_at, ( + f"session 2 starts at {sessions[1].started_at}, inside session 1's " + f"window which ends at {sessions[0].ended_at}" + ) + + +# --------------------------------------------------------------------------- +# awaiting +# --------------------------------------------------------------------------- + + +class TestAwaiting: + async def test_contact_loss_closes_at_the_last_movement( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """`ended_at` is the end of the burst, and no timeout may stamp it. + + `check_session_timeouts` used to select on `last_seen` and then call + `end_session(device, last_seen)` -- and `update_device_status` sets + `last_seen` on EVERY call, heartbeat included. Changing only the + selection predicate would leave `ended_at` at the last contact, padding + every drive's tail by up to one heartbeat interval (95 minutes, + measured) and dragging `avg_speed` toward zero with parked samples. + """ + vin, device = await make_livelink_vehicle("smawait", "1") + service = SessionService(db_session) + + last_movement = T0 + timedelta(minutes=1) + await _drive(service, db_session, vin, device, [(T0, MOVING), (last_movement, MOVING)]) + # A parked heartbeat arrives well after movement stopped. + heartbeat = T0 + timedelta(minutes=30) + device.last_seen = heartbeat + await service.observe_telemetry(device, BATTERY, heartbeat, live=True) + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=heartbeat + timedelta(minutes=10) + ) + await db_session.flush() + + session = (await _all_sessions(db_session, device.device_id))[0] + assert session.ended_at == last_movement + + async def test_movement_returning_inside_the_gap_reopens_the_same_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """`awaiting` exists to make live and replay agree. + + Without it: movement, six minutes of silence, movement produces TWO + live sessions (the five-minute contact timeout fired) and ONE replayed + session (the movement gap was under fifteen). The claim that a drive is + cut the same way whichever path it arrived by would be aspirational. + """ + vin, device = await make_livelink_vehicle("smawait", "2") + service = SessionService(db_session) + + await _drive( + service, db_session, vin, device, [(T0, MOVING), (T0 + timedelta(minutes=1), MOVING)] + ) + original = await _open_session(db_session, device.device_id) + assert original is not None + original_id = original.id + + # Six minutes of total silence: past the contact timeout, inside the gap. + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=T0 + timedelta(minutes=7) + ) + await db_session.flush() + assert await _open_session(db_session, device.device_id) is None, ( + "the contact timeout must still CLOSE promptly, so a device that " + "never returns is not left open forever" + ) + + resumed = T0 + timedelta(minutes=8) + await _drive( + service, + db_session, + vin, + device, + [(resumed, MOVING), (resumed + timedelta(seconds=30), MOVING)], + ) + + sessions = await _all_sessions(db_session, device.device_id) + assert len(sessions) == 1, f"expected the drive to be one session, got {len(sessions)}" + assert sessions[0].id == original_id, "the retained session must be REOPENED, not replaced" + assert sessions[0].ended_at is None + + async def test_movement_returning_after_the_gap_starts_a_new_session( + self, db_session: AsyncSession, make_livelink_vehicle + ): + vin, device = await make_livelink_vehicle("smawait", "3") + service = SessionService(db_session) + + await _drive( + service, db_session, vin, device, [(T0, MOVING), (T0 + timedelta(minutes=1), MOVING)] + ) + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=T0 + timedelta(minutes=7) + ) + await db_session.flush() + + resumed = T0 + timedelta(minutes=40) + await _drive( + service, + db_session, + vin, + device, + [(resumed, MOVING), (resumed + timedelta(minutes=1), MOVING)], + ) + + sessions = await _all_sessions(db_session, device.device_id) + assert len(sessions) == 2 + assert sessions[0].ended_at is not None + + async def test_the_gap_elapsing_makes_the_closure_final( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Once finalized, the pointer is clear and nothing can reopen it.""" + vin, device = await make_livelink_vehicle("smawait", "4") + service = SessionService(db_session) + + await _drive( + service, db_session, vin, device, [(T0, MOVING), (T0 + timedelta(minutes=1), MOVING)] + ) + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=T0 + timedelta(minutes=7) + ) + await service.expire_stale_movement_state(gap_minutes=GAP, now=T0 + timedelta(minutes=40)) + await db_session.flush() + + assert device.current_session_id is None + + +# --------------------------------------------------------------------------- +# Explicit ECU-offline and its grace period +# --------------------------------------------------------------------------- + + +class TestExplicitOffline: + async def test_the_finalizer_closes_the_session_directly( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Not by looking for an online-to-offline transition, which no-ops. + + The route persists `ecu_status='offline'` immediately, so when the + 60-second grace expires `handle_ecu_offline` sees no transition and does + nothing -- leaving the session to a contact timeout anchored on a + `last_seen` that the finalizer itself advanced. The existing tests mock + `handle_ecu_offline` and assert only that it was called, so they pass + with this broken. + """ + vin, device = await make_livelink_vehicle("smoff", "1") + service = SessionService(db_session) + + last_movement = T0 + timedelta(minutes=1) + await _drive(service, db_session, vin, device, [(T0, MOVING), (last_movement, MOVING)]) + device.ecu_status = "offline" # what the route already wrote + + closed = await service.finalize_offline(device, now=T0 + timedelta(minutes=2)) + await db_session.flush() + + assert closed is not None, "the finalizer must close the session, transition or not" + assert closed.ended_at == last_movement + + async def test_the_finalizer_does_not_touch_last_seen( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """There was no contact. Fabricating one corrupts every timeout that + reads `last_seen` -- including the contact-loss clock, which would then + measure from a moment the device never spoke.""" + vin, device = await make_livelink_vehicle("smoff", "2") + service = SessionService(db_session) + + await _drive( + service, db_session, vin, device, [(T0, MOVING), (T0 + timedelta(minutes=1), MOVING)] + ) + before = device.last_seen + + await service.finalize_offline(device, now=T0 + timedelta(minutes=2)) + await db_session.flush() + + assert device.last_seen == before + + async def test_a_dropout_inside_the_grace_keeps_the_pending_envelope( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Pending state clears when an offline FINALIZES, not when it arrives. + + Taken separately, "clear pending on explicit offline" and "treat offline + as provisional for 60 seconds" mean a brief WiFi drop discards the + warm-up and opening-odometer samples that the pending state exists to + preserve. + """ + vin, device = await make_livelink_vehicle("smoff", "3") + service = SessionService(db_session) + + await _drive(service, db_session, vin, device, [(T0, IDLING)]) + assert device.pending_since == T0 + + # Offline arrives; the grace period has not expired. + await service.begin_provisional_offline(device, now=T0 + timedelta(seconds=10)) + await db_session.flush() + assert device.pending_since == T0, "provisional offline must not discard the warm-up" + + # Contact returns inside the grace, then the vehicle moves. + await _drive( + service, + db_session, + vin, + device, + [(T0 + timedelta(minutes=1), MOVING), (T0 + timedelta(minutes=2), MOVING)], + ) + + session = await _open_session(db_session, device.device_id) + assert session is not None + assert session.started_at == T0 + + async def test_finalizing_clears_the_pending_state( + self, db_session: AsyncSession, make_livelink_vehicle + ): + vin, device = await make_livelink_vehicle("smoff", "4") + service = SessionService(db_session) + + await _drive(service, db_session, vin, device, [(T0, IDLING)]) + await service.finalize_offline(device, now=T0 + timedelta(minutes=2)) + await db_session.flush() + + assert device.pending_since is None + assert await _all_sessions(db_session, device.device_id) == [] + + +# --------------------------------------------------------------------------- +# Scope and provenance +# --------------------------------------------------------------------------- + + +class TestScopeAndProvenance: + async def test_movement_state_is_per_device_not_per_vin( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """A vehicle with both a WiCAN dongle and a Torque phone would otherwise + let one source confirm the other's pending drive.""" + vin, wican = await make_livelink_vehicle("smscope", "1") + second = type(wican)(device_id="smscopedev0002", vin=vin, enabled=True, kind="torque") + db_session.add(second) + await db_session.flush() + + service = SessionService(db_session) + await _drive(service, db_session, vin, wican, [(T0, IDLING)]) + await _drive(service, db_session, vin, second, [(T0 + timedelta(minutes=1), MOVING)]) + + assert await _all_sessions(db_session, wican.device_id) == [], ( + "the second device's movement must not promote the first's pending drive" + ) + assert wican.pending_since == T0, "the first device's pending drive is untouched" + assert wican.movement_candidate_at is None, ( + "and the second device's above-floor sample is not the first's candidate" + ) + assert second.movement_candidate_at == T0 + timedelta(minutes=1) + + async def test_new_sessions_are_stamped_with_the_new_algorithm( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Defaulting new rows to 0 is not half-right, it is wrong: they would + masquerade as pre-098 history and any later pass over history would skip + them.""" + vin, device = await make_livelink_vehicle("smscope", "2") + service = SessionService(db_session) + + await _drive( + service, db_session, vin, device, [(T0, MOVING), (T0 + timedelta(minutes=1), MOVING)] + ) + + session = await _open_session(db_session, device.device_id) + assert session is not None + assert session.boundary_algorithm_version == 1 + assert session.effective_gap_minutes == GAP + + async def test_starting_a_session_does_not_write_ecu_status( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """C8. Once a session is not a proxy for ECU state, a movement timeout + marking an awake ECU offline is not cosmetic: `device_command_service` + refuses any `requires_ecu` command when `ecu_status != 'online'`, so the + remote-command surface would go dead after every drive.""" + vin, device = await make_livelink_vehicle("smscope", "3") + device.ecu_status = "online" + service = SessionService(db_session) + + await _drive( + service, db_session, vin, device, [(T0, MOVING), (T0 + timedelta(minutes=1), MOVING)] + ) + assert device.ecu_status == "online" + + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=T0 + timedelta(minutes=30) + ) + await db_session.flush() + + assert device.ecu_status == "online", "closing a session must not mark an awake ECU offline" + + async def test_replayed_samples_do_not_anchor_live_movement( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """An HTTPS payload carrying an old or future timestamp must not anchor + a live session hours away from where it belongs.""" + vin, device = await make_livelink_vehicle("smscope", "4") + service = SessionService(db_session) + + await _drive( + service, + db_session, + vin, + device, + [(T0, MOVING), (T0 + timedelta(minutes=1), MOVING)], + live=False, + ) + + assert device.last_movement_at is None, ( + "replay must not write last_movement_at, which every live timeout reads" + ) + + +# --------------------------------------------------------------------------- +# The window keeps the opening odometer (C5, measured end to end) +# --------------------------------------------------------------------------- + + +class TestTheWindowKeepsTheOpeningOdometer: + async def test_the_opening_odometer_reading_is_inside_the_window( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """C5, driven end to end: ingest, boundaries, close, distance. + + Aggregates are strictly window-bounded, so a `started_at` at the first + MOVEMENT sample drops every reading taken at ignition -- warm-up + coolant, initial fuel level, and the opening odometer. + + Measured under mutation (`started_at = sample_at`): `start_odometer` + becomes 120001 and the drive reports 11 km instead of 12. The design + also warns of a worse form, a window left holding exactly ONE odometer + sample, where `_calculate_session_distance` assigns unconditionally and + writes a confident `distance_km = 0.0` rather than a blank. This test + does not reproduce that -- with three odometer samples in the drive it + cannot -- so it is named for what it does pin: the opening reading stays + in the window. Keeping it there is what makes single-sample windows rare + rather than routine. + """ + vin, device = await make_livelink_vehicle("smwindow", "1") + service = SessionService(db_session) + + batches = [ + # Ignition on: engine turning, odometer read, nothing moving yet. + (T0, {"ENGINE_RPM": 700.0, "SPEED": 0.0, "A6-ODOMETER": 120_000.0}), + (T0 + timedelta(minutes=2), {"SPEED": 40.0, "A6-ODOMETER": 120_001.0}), + (T0 + timedelta(minutes=3), {"SPEED": 55.0, "A6-ODOMETER": 120_003.0}), + (T0 + timedelta(minutes=10), {"SPEED": 50.0, "A6-ODOMETER": 120_012.0}), + ] + await _drive(service, db_session, vin, device, batches, persist=True) + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=T0 + timedelta(minutes=40) + ) + await db_session.flush() + + session = (await _all_sessions(db_session, device.device_id))[0] + assert session.started_at == T0 + assert session.start_odometer == 120_000.0, ( + "the opening odometer reading fell outside the window" + ) + assert session.distance_km == pytest.approx(12.0) diff --git a/backend/tests/unit/services/test_session_stationary_filter.py b/backend/tests/unit/services/test_session_stationary_filter.py new file mode 100644 index 00000000..ea84c877 --- /dev/null +++ b/backend/tests/unit/services/test_session_stationary_filter.py @@ -0,0 +1,152 @@ +"""The drive list can hide sessions in which nothing moved. + +Sessions used to open whenever the dongle could reach the broker, and a parked +WiCAN checks in about every 95 minutes. On the instance this was built against +**2,921 of 3,262 recorded sessions never moved at all**: no distance, top speed +under the 5 km/h floor. They are not drives, and they are not going to become +drives, because the telemetry needed to rebuild them was never captured +(`app/utils/distance_counters.py`). + +Deleting them is not the answer, and an earlier revision of this release shipped +a tool that tried: it removed 2,700 km of real recorded distance and created +nothing. So the list narrows and every row stays where it is. + +WHY NOT FILTER ON `boundary_algorithm_version` +---------------------------------------------- +Because it is the wrong question, and the first version of this asked it. Of +those 3,262 pre-098 sessions **341 record a vehicle that demonstrably moved**. +Hiding by algorithm buries all 341 along with the noise, and they are the user's +own history of real journeys. What makes a row worthless is that nothing moved, +not which rule cut it. +""" + +from datetime import timedelta + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.drive_session import DriveSession +from app.services.session_service import SessionService +from app.utils.datetime_utils import utc_now + + +@pytest_asyncio.fixture +async def seeded(db_session: AsyncSession, make_livelink_vehicle): + """A fleet covering every combination the filter has to separate.""" + vin, device = await make_livelink_vehicle("slgcy", "1") + anchor = utc_now() - timedelta(days=10) + rows = [ + # Old rule, nothing moved: the parked heartbeat cohort. + dict(boundary_algorithm_version=0, distance_km=0.0, max_speed=0.0), + dict(boundary_algorithm_version=0, distance_km=None, max_speed=None), + dict(boundary_algorithm_version=0, distance_km=0.0, max_speed=2.0), + # Old rule, but a REAL drive. Must survive the filter. + dict(boundary_algorithm_version=0, distance_km=12.4, max_speed=64.0), + dict(boundary_algorithm_version=0, distance_km=0.0, max_speed=48.0), + # New rule, a real drive. + dict(boundary_algorithm_version=1, distance_km=8.0, max_speed=55.0), + ] + for index, row in enumerate(rows): + db_session.add( + DriveSession( + vin=vin, + device_id=device.device_id, + started_at=anchor + timedelta(hours=index), + ended_at=anchor + timedelta(hours=index, minutes=20), + **row, + ) + ) + await db_session.flush() + return vin, device + + +@pytest.mark.asyncio +class TestStationarySessionFilter: + async def test_the_list_includes_everything_by_default(self, db_session, seeded): + """The default must not silently drop history from an existing caller.""" + vin, _ = seeded + + sessions = await SessionService(db_session).get_vehicle_sessions(vin=vin) + + assert len(sessions) == 6 + + async def test_excluding_stationary_keeps_every_drive_that_moved(self, db_session, seeded): + """Three moved: two under the old rule, one under the new.""" + vin, _ = seeded + + sessions = await SessionService(db_session).get_vehicle_sessions( + vin=vin, include_stationary=False + ) + + assert len(sessions) == 3 + + async def test_a_real_drive_recorded_by_the_old_rule_survives(self, db_session, seeded): + """The 341, and the reason this does not filter on the algorithm version. + + A pre-098 session that covered 12.4 km at 64 km/h is a journey the user + took. Hiding it because of HOW it was detected discards real history to + tidy up a display. + """ + vin, _ = seeded + + sessions = await SessionService(db_session).get_vehicle_sessions( + vin=vin, include_stationary=False + ) + + legacy_kept = [s for s in sessions if s.boundary_algorithm_version == 0] + assert len(legacy_kept) == 2 + assert any(s.distance_km == 12.4 for s in legacy_kept) + + async def test_speed_alone_is_enough_to_keep_a_drive(self, db_session, seeded): + """A drive whose odometer never ticked still moved. + + This is the whole Mirage cohort: an odometer that steps every 24 km + reports zero distance for most trips, so requiring distance would hide + real drives on exactly the hardware this release exists for. + """ + vin, _ = seeded + + sessions = await SessionService(db_session).get_vehicle_sessions( + vin=vin, include_stationary=False + ) + + assert any(s.distance_km == 0.0 and s.max_speed == 48.0 for s in sessions) + + async def test_a_null_speed_is_not_evidence_of_movement(self, db_session, seeded): + """A pruned session cannot prove it was a drive, so it reads as stationary. + + The alternative shows every unprovable row and defeats the filter: on + the measured data 2,816 sessions have no speed on record and only 5 of + them carry any distance. + """ + vin, _ = seeded + + sessions = await SessionService(db_session).get_vehicle_sessions( + vin=vin, include_stationary=False + ) + + assert not any(s.max_speed is None for s in sessions) + + async def test_the_count_matches_the_filter(self, db_session, seeded): + """A total counting rows the list refuses to show breaks pagination.""" + vin, _ = seeded + service = SessionService(db_session) + + assert await service.get_session_count(vin) == 6 + assert await service.get_session_count(vin, include_stationary=False) == 3 + + async def test_the_stationary_count_is_available_on_its_own(self, db_session, seeded): + """So the empty state can say how many drives are being held back.""" + vin, _ = seeded + + assert await SessionService(db_session).get_stationary_session_count(vin) == 3 + + async def test_nothing_is_deleted_by_filtering(self, db_session, seeded): + """The rows stay; only the view narrows.""" + vin, _ = seeded + service = SessionService(db_session) + + await service.get_vehicle_sessions(vin=vin, include_stationary=False) + + assert await service.get_session_count(vin) == 6 diff --git a/backend/tests/unit/services/test_tire_distance.py b/backend/tests/unit/services/test_tire_distance.py new file mode 100644 index 00000000..c1418569 --- /dev/null +++ b/backend/tests/unit/services/test_tire_distance.py @@ -0,0 +1,275 @@ +"""`distance_on_tire`: what a tire has actually rolled. + +The defect this replaces: `_project_wear` computed +`newer.odometer_km - older.odometer_km` and called it distance driven ON THAT +TIRE. For anyone running a second seasonal set that counts the distance driven +on the OTHER set, and the reported remaining life came out at 648,000 km +against a 2.0 mm threshold -- erring high, which for a tire is the dangerous +direction. + +Every status has a test, driven from the enum itself rather than a list typed +out here, so adding a member without a test fails. +""" + +from __future__ import annotations + +import datetime as dt +from decimal import Decimal + +import pytest + +from app.models.tire import Tire, TireMountPeriod +from app.services.tire_results import DistanceStatus +from app.services.tire_service import distance_on_tire + + +def _period(**kw) -> TireMountPeriod: + defaults = dict( + position="FL", + mounted_on=dt.date(2026, 1, 1), + dismounted_on=None, + mounted_odometer_km=None, + dismounted_odometer_km=None, + is_assumed=False, + observed_active_on=None, + ) + defaults.update(kw) + return TireMountPeriod(**defaults) + + +def _tire(periods: list[TireMountPeriod]) -> Tire: + tire = Tire(vin="V" * 17, position="FL") + tire.mount_periods = periods + return tire + + +class TestDistanceOnTire: + def test_no_periods_is_a_state_not_a_zero(self): + result = distance_on_tire(_tire([]), current_odometer=Decimal("1000")) + assert result.status is DistanceStatus.NO_PERIODS + assert result.all_time_value is None + assert result.known_value is None + + def test_a_spare_only_tire_has_never_rolled(self): + """A tire in the trunk does not wear while the vehicle drives. + + An earlier draft returned a confident 0 here: it skipped spares before + the odometer check and then returned the running total, still zero. So + the one case the design forbade rendering was the one case that + produced a number. + """ + result = distance_on_tire( + _tire([_period(position="SPARE", mounted_odometer_km=Decimal("100"))]), + current_odometer=Decimal("5000"), + ) + assert result.status is DistanceStatus.SPARE_ONLY + assert result.all_time_value is None + assert result.known_value is None + + def test_a_closed_bounded_period_is_complete(self): + result = distance_on_tire( + _tire( + [ + _period( + mounted_odometer_km=Decimal("1000"), + dismounted_on=dt.date(2026, 6, 1), + dismounted_odometer_km=Decimal("9000"), + ) + ] + ), + current_odometer=Decimal("12000"), + ) + assert result.status is DistanceStatus.COMPLETE + assert result.all_time_value == Decimal("8000") + assert result.known_value == Decimal("8000") + assert result.known_since == dt.date(2026, 1, 1) + + def test_an_open_period_is_bounded_by_the_current_odometer(self): + result = distance_on_tire( + _tire([_period(mounted_odometer_km=Decimal("1000"))]), + current_odometer=Decimal("4000"), + ) + assert result.status is DistanceStatus.COMPLETE + assert result.all_time_value == Decimal("3000") + + def test_an_open_period_with_no_vehicle_odometer_is_incomplete(self): + """A vehicle with no OdometerRecord makes `current_odometer` null, so + the open period has no upper bound. Same shape as a missing lower + bound: supply a number.""" + result = distance_on_tire( + _tire([_period(mounted_odometer_km=Decimal("1000"))]), + current_odometer=None, + ) + assert result.status is DistanceStatus.NOTHING_BOUNDED + + def test_the_migrated_shape_is_nothing_bounded(self): + """Upgrade day, for essentially every tire on every instance. + + 097 gives each existing tire one assumed period with a NULL start + odometer. Reporting `known_value=0, known_since=None` here would read + as "we measured zero kilometres since an unknown date", which is worse + than reporting nothing. + """ + result = distance_on_tire( + _tire( + [_period(mounted_on=None, is_assumed=True, observed_active_on=dt.date(2026, 9, 2))] + ), + current_odometer=Decimal("5000"), + ) + assert result.status is DistanceStatus.NOTHING_BOUNDED + assert result.all_time_value is None + assert result.known_value is None + assert result.known_since is None + + def test_a_partially_known_history_still_reports_what_it_knows(self): + """The fix for "a migrated tire reports nothing forever". + + Recording a later real mount does not give the earlier assumed period + a start bound, so the all-time total stays unknown -- but the newer + period IS measurable and its figure is returned. + """ + result = distance_on_tire( + _tire( + [ + _period(id=1, mounted_on=None, is_assumed=True), + _period( + id=2, + mounted_on=dt.date(2026, 5, 1), + mounted_odometer_km=Decimal("10000"), + dismounted_on=dt.date(2026, 8, 1), + dismounted_odometer_km=Decimal("14000"), + ), + ] + ), + current_odometer=Decimal("15000"), + ) + assert result.status is DistanceStatus.INCOMPLETE + assert result.all_time_value is None + assert result.known_value == Decimal("4000") + assert result.known_since == dt.date(2026, 5, 1) + assert result.blocking_period_ids == [1] + + def test_known_since_ignores_a_period_that_contributed_nothing(self): + """An assumed period's `mounted_on` can be null, and folding it into a + min() would either raise or poison the date.""" + result = distance_on_tire( + _tire( + [ + _period(id=1, mounted_on=None, is_assumed=True), + _period( + id=2, + mounted_on=dt.date(2026, 5, 1), + mounted_odometer_km=Decimal("10000"), + dismounted_on=dt.date(2026, 8, 1), + dismounted_odometer_km=Decimal("14000"), + ), + ] + ), + current_odometer=Decimal("15000"), + ) + assert result.known_since == dt.date(2026, 5, 1) + + def test_a_backwards_period_is_a_fault_not_a_gap(self): + result = distance_on_tire( + _tire( + [ + _period( + id=7, + mounted_odometer_km=Decimal("9000"), + dismounted_on=dt.date(2026, 6, 1), + dismounted_odometer_km=Decimal("1000"), + ) + ] + ), + current_odometer=Decimal("12000"), + ) + assert result.status is DistanceStatus.ODOMETER_ROLLBACK + assert result.all_time_value is None + assert result.blocking_period_ids == [7] + + def test_spare_periods_do_not_contribute_to_a_mixed_history(self): + """The original defect, in miniature: a tire that spent part of its + life as a spare must not be credited with the distance driven while it + sat in the trunk.""" + result = distance_on_tire( + _tire( + [ + _period( + position="SPARE", + mounted_odometer_km=Decimal("0"), + dismounted_on=dt.date(2026, 3, 1), + dismounted_odometer_km=Decimal("50000"), + ), + _period( + position="FL", + mounted_on=dt.date(2026, 3, 1), + mounted_odometer_km=Decimal("50000"), + dismounted_on=dt.date(2026, 6, 1), + dismounted_odometer_km=Decimal("52000"), + ), + ] + ), + current_odometer=Decimal("52000"), + ) + assert result.status is DistanceStatus.COMPLETE + assert result.all_time_value == Decimal("2000"), ( + "only the FL period counts; the 50,000 km spent as a spare is not " + "distance driven on this tire" + ) + + +class TestEveryStatusIsReachable: + """Guards the guard. + + A status nothing can produce is a status no caller will ever be tested + against, and the caller's fall-through will be found by a user instead. + Parametrized over the ENUM, so adding a member without making it reachable + fails here rather than passing silently. + """ + + PRODUCERS = { + DistanceStatus.NO_PERIODS: lambda: distance_on_tire(_tire([]), Decimal("1")), + DistanceStatus.SPARE_ONLY: lambda: distance_on_tire( + _tire([_period(position="SPARE")]), Decimal("1") + ), + DistanceStatus.NOTHING_BOUNDED: lambda: distance_on_tire( + _tire([_period(mounted_on=None)]), Decimal("1") + ), + DistanceStatus.INCOMPLETE: lambda: distance_on_tire( + _tire( + [ + _period(id=1), + _period( + id=2, + mounted_odometer_km=Decimal("1"), + dismounted_on=dt.date(2026, 2, 1), + dismounted_odometer_km=Decimal("2"), + ), + ] + ), + Decimal("3"), + ), + DistanceStatus.COMPLETE: lambda: distance_on_tire( + _tire([_period(mounted_odometer_km=Decimal("1"))]), Decimal("2") + ), + DistanceStatus.ODOMETER_ROLLBACK: lambda: distance_on_tire( + _tire( + [ + _period( + mounted_odometer_km=Decimal("9"), + dismounted_on=dt.date(2026, 2, 1), + dismounted_odometer_km=Decimal("1"), + ) + ] + ), + Decimal("9"), + ), + } + + @pytest.mark.parametrize("status", list(DistanceStatus), ids=lambda s: s.value) + def test_status_has_a_producer(self, status: DistanceStatus): + assert status in self.PRODUCERS, ( + f"{status} has no producer here, so nothing proves the calculation " + f"can emit it and no caller test can be written against it" + ) + assert self.PRODUCERS[status]().status is status diff --git a/backend/tests/unit/services/test_tire_readiness.py b/backend/tests/unit/services/test_tire_readiness.py new file mode 100644 index 00000000..715eeadb --- /dev/null +++ b/backend/tests/unit/services/test_tire_readiness.py @@ -0,0 +1,237 @@ +"""Readiness: how many tires can answer each question, and what is missing. + +Spec B leads with this block because of a measurement rather than a taste. On +the instance that asked for tire analytics there were two tires, two readings +and zero readings carrying an odometer, so every analytical block would have +rendered empty. The counts here are what turns that page from an apology into +an instruction. + +**The three requirements are independent, and the point of this file is that +they are counted independently.** `project_wear` short-circuits in a fixed +order, so a tire missing both a minimum tread and its reading odometers reports +only `no_minimum_set`. A readiness block built from that status alone would +name one problem and hide the other. +""" + +from __future__ import annotations + +import datetime as dt +from decimal import Decimal + +from app.schemas.analytics import TireReadiness +from app.schemas.tire import TireReadingResponse, TireResponse +from app.services.analytics_service.tires import tire_readiness + +CREATED = dt.datetime(2026, 1, 1, 12, 0, 0) + + +def reading( + day: int, + *, + tread: str | None = "8.0", + odometer: str | None = "10000", +) -> TireReadingResponse: + return TireReadingResponse( + id=day, + tire_id=1, + vin="1HGCM82633A004352", + position="FL", + recorded_at=dt.date(2026, 1, day), + odometer_km=None if odometer is None else Decimal(odometer), + tread_depth_mm=None if tread is None else Decimal(tread), + pressure_kpa=None, + notes=None, + created_at=CREATED, + ) + + +def tire( + tire_id: int = 1, + *, + readings: list[TireReadingResponse] | None = None, + wear_status: str = "insufficient_readings", + distance_status: str = "no_periods", + min_tread_mm: str | None = "2.0", + below_threshold: bool = False, + retired_on: dt.date | None = None, +) -> TireResponse: + return TireResponse( + id=tire_id, + vin="1HGCM82633A004352", + position="FL", + min_tread_mm=None if min_tread_mm is None else Decimal(min_tread_mm), + below_threshold=below_threshold, + retired_on=retired_on, + wear_status=wear_status, + distance_status=distance_status, + created_at=CREATED, + readings=readings or [], + ) + + +class TestTheCapabilityCounts: + def test_an_empty_vehicle_counts_nothing(self): + assert tire_readiness([]) == TireReadiness() + + def test_two_tread_readings_make_a_trend(self): + result = tire_readiness([tire(readings=[reading(1), reading(2)])]) + assert result.total == 1 + assert result.can_trend == 1 + assert result.needs_second_reading == 0 + + def test_one_reading_is_a_point(self): + """Distinct from "no distance data", and must not share its wording.""" + result = tire_readiness([tire(readings=[reading(1)])]) + assert result.can_trend == 0 + assert result.needs_second_reading == 1 + + def test_a_pressure_only_reading_does_not_count_toward_a_trend(self): + """Since #152 a reading can carry a pressure and no tread. + + Two of those are two points on a chart that has no y value. + """ + result = tire_readiness([tire(readings=[reading(1, tread=None), reading(2, tread=None)])]) + assert result.can_trend == 0 + assert result.needs_second_reading == 1 + + def test_only_a_real_figure_counts_as_projected(self): + """`at_or_below_minimum` is the safety case and carries a number. + + The other six statuses are prompts. Counting any of them as an answer + is how a readiness block tells someone they are done when they are not. + """ + tires = [ + tire(1, wear_status="projected"), + tire(2, wear_status="at_or_below_minimum"), + tire(3, wear_status="unverified_mount_history"), + tire(4, wear_status="no_distance_on_tire"), + ] + assert tire_readiness(tires).can_project == 2 + + def test_a_partial_distance_is_a_prompt_not_an_answer(self): + """`incomplete` DOES report its measurable part, and still counts as + unfinished: there is a period the user can go and complete.""" + tires = [ + tire(1, distance_status="complete"), + tire(2, distance_status="incomplete"), + ] + result = tire_readiness(tires) + assert result.can_report_distance == 1 + assert result.needs_mount_odometer == 1 + + +class TestThePromptsAreIndependent: + """The failure spec B calls out by name.""" + + def test_a_tire_missing_a_minimum_and_odometers_reports_both(self): + """`project_wear` answers `no_minimum_set` and stops. + + Reading the prompts off that status would hide the odometers entirely, + and the user would fix the minimum, come back, and find the block still + empty with no new advice. + """ + result = tire_readiness( + [ + tire( + readings=[reading(1, odometer=None), reading(2, odometer=None)], + min_tread_mm=None, + wear_status="no_minimum_set", + ) + ] + ) + assert result.needs_minimum_tread == 1 + assert result.needs_reading_odometer == 1 + + def test_distance_does_not_ask_for_reading_odometers(self): + """Distance reads period bounds and the vehicle odometer, never a + reading's odometer. Telling this owner to add odometers to their tread + readings would be advice about the wrong data.""" + result = tire_readiness( + [ + tire( + readings=[reading(1), reading(2)], + wear_status="projected", + distance_status="nothing_bounded", + ) + ] + ) + assert result.needs_reading_odometer == 0 + assert result.needs_mount_odometer == 1 + + def test_only_the_newest_pair_of_odometers_matters(self): + """`project_wear` differences the newest two tread-bearing readings. + + A third, older reading without an odometer costs nothing, and counting + it would prompt for a number that changes no figure. + """ + result = tire_readiness( + [tire(readings=[reading(3), reading(2), reading(1, odometer=None)])] + ) + assert result.can_trend == 1 + assert result.needs_reading_odometer == 0 + + def test_a_missing_odometer_on_the_newest_pair_does_count(self): + result = tire_readiness( + [tire(readings=[reading(3, odometer=None), reading(2), reading(1)])] + ) + assert result.needs_reading_odometer == 1 + + def test_the_older_of_the_pair_counts_too(self): + """Both sides, because the figure is a DIFFERENCE. + + Its pair above has the NEWEST reading missing its odometer; this one + has the older. A check that looked at only one of the two would pass + one of these and fail the other, which is the whole reason both exist. + """ + result = tire_readiness( + [tire(readings=[reading(3), reading(2, odometer=None), reading(1)])] + ) + assert result.needs_reading_odometer == 1 + + +class TestWhatAMountOdometerCannotFix: + """Two distance statuses are not gaps, and must not be prompted for.""" + + def test_a_spare_that_has_never_rolled_is_a_state(self): + result = tire_readiness([tire(distance_status="spare_only")]) + assert result.needs_mount_odometer == 0 + + def test_reversed_data_needs_a_correction_not_a_number(self): + result = tire_readiness([tire(distance_status="odometer_rollback")]) + assert result.needs_mount_odometer == 0 + + +class TestRetiredTires: + """B10: they belong in the history blocks and in none of these counts.""" + + def test_a_retired_tire_counts_toward_nothing(self): + retired = tire( + 1, + readings=[reading(1), reading(2)], + wear_status="projected", + distance_status="complete", + below_threshold=True, + retired_on=dt.date(2026, 2, 1), + ) + assert tire_readiness([retired]) == TireReadiness() + + def test_a_retired_tire_does_not_dilute_the_live_ones(self): + """The count that matters is "how many of the tires I can still act on". + + A vehicle that has replaced three sets would otherwise read as mostly + unready forever, and the advice would never go away. + """ + live = tire(1, readings=[reading(1), reading(2)], wear_status="projected") + retired = tire(2, retired_on=dt.date(2026, 2, 1)) + result = tire_readiness([live, retired]) + assert result.total == 1 + assert result.can_trend == 1 + assert result.can_project == 1 + + +class TestUnderMinimum: + def test_a_worn_tire_is_counted_as_an_action(self): + """What an analytics page adds over a card: the card shows a badge on + the tire, this says how many there are without opening the tab.""" + tires = [tire(1, below_threshold=True), tire(2, below_threshold=False)] + assert tire_readiness(tires).under_minimum == 1 diff --git a/backend/tests/unit/services/test_torque_timeout_unchanged.py b/backend/tests/unit/services/test_torque_timeout_unchanged.py new file mode 100644 index 00000000..6683de85 --- /dev/null +++ b/backend/tests/unit/services/test_torque_timeout_unchanged.py @@ -0,0 +1,154 @@ +"""A Torque device's timeout behaviour is byte-identical before and after. C8. + +`check_session_timeouts` has no `kind` filter, and the new drive-gap clock reads +`last_movement_at` / `movement_ended_at` / `movement_started_at`. A Torque +session has NONE of those: `resolve_torque_session` never calls the movement +observer, deliberately, because the phone supplies an authoritative session id +and a movement predicate has nothing to add. + +So the fallback chain lands on `started_at`, and the gap clock would then close +an actively-uploading Torque trip fifteen minutes after it BEGAN. That is not a +subtle regression: a one-hour drive would be cut into a fifteen-minute session +plus forty-five minutes attributed to nothing, on a source that was working +correctly and that this whole change was supposed to leave alone. + +The old rule is the whole rule for Torque: close on contact loss, measured from +`last_seen`, which `resolve_torque_session` stamps on every upload. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.drive_session import DriveSession +from app.services.session_service import SessionService + +pytestmark = pytest.mark.asyncio + +T0 = datetime(2026, 9, 1, 8, 0, 0) +GAP = 15 +TIMEOUT = 5 + + +async def _session_row(db: AsyncSession, session_id: int) -> DriveSession: + return ( + await db.execute(select(DriveSession).where(DriveSession.id == session_id)) + ).scalar_one() + + +class TestTorqueTimeouts: + async def test_an_uploading_trip_is_not_closed_by_the_drive_gap( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The regression the fallback chain would cause. + + Forty minutes into a drive, phone still uploading. Under the drive gap + this session's only movement anchor is `started_at`, forty minutes ago, + so it would be closed mid-trip. + """ + vin, device = await make_livelink_vehicle("tqto", "1", kind="torque") + service = SessionService(db_session) + + session = await service.resolve_torque_session(device, "phone-1", T0) + await db_session.flush() + assert session is not None + session_id = session.id + + # The phone is still uploading: `resolve_torque_session` stamps + # `last_seen` on every packet. + now = T0 + timedelta(minutes=40) + device.last_seen = now + await db_session.flush() + + await service.check_session_timeouts(timeout_minutes=TIMEOUT, gap_minutes=GAP, now=now) + await db_session.flush() + + assert (await _session_row(db_session, session_id)).ended_at is None, ( + "the drive gap closed an actively-uploading Torque trip; its " + "boundaries come from the phone, not from this algorithm" + ) + + async def test_it_is_still_closed_on_contact_loss( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The paired control. Without it the test above is satisfied by a + Torque session that no clock can ever close, which leaks open forever + and blocks every later session through the open-session index.""" + vin, device = await make_livelink_vehicle("tqto", "2", kind="torque") + service = SessionService(db_session) + + session = await service.resolve_torque_session(device, "phone-2", T0) + await db_session.flush() + assert session is not None + session_id = session.id + + device.last_seen = T0 + timedelta(minutes=1) + await db_session.flush() + + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=T0 + timedelta(minutes=20) + ) + await db_session.flush() + + assert (await _session_row(db_session, session_id)).ended_at is not None + + async def test_contact_loss_closes_it_at_the_last_contact( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """Not at a movement timestamp it does not have. + + A WiCAN session closes at its last MOVEMENT, so the tail of parked + heartbeats is trimmed. A Torque session has no movement record at all, + and `last_seen` is the best evidence of when the trip ended -- which is + exactly what the old rule used. + """ + vin, device = await make_livelink_vehicle("tqto", "3", kind="torque") + service = SessionService(db_session) + + session = await service.resolve_torque_session(device, "phone-3", T0) + await db_session.flush() + session_id = session.id + + last_upload = T0 + timedelta(minutes=12) + device.last_seen = last_upload + await db_session.flush() + + await service.check_session_timeouts( + timeout_minutes=TIMEOUT, gap_minutes=GAP, now=T0 + timedelta(minutes=30) + ) + await db_session.flush() + + assert (await _session_row(db_session, session_id)).ended_at == last_upload + + async def test_a_wican_session_is_still_cut_by_the_drive_gap( + self, db_session: AsyncSession, make_livelink_vehicle + ): + """The control on the exemption. + + Whatever distinguishes Torque here must not accidentally exempt WiCAN + too, which would restore the phantom sessions this release removes. + """ + vin, device = await make_livelink_vehicle("tqto", "4") + service = SessionService(db_session) + + moved_at = T0 + timedelta(minutes=1) + for at in (T0, moved_at): + device.last_seen = at + await service.observe_telemetry(device, {"SPEED": 48.0}, at, live=True) + await db_session.flush() + session_id = device.current_session_id + assert session_id is not None + + # Still connected, stationary for twenty minutes. + now = T0 + timedelta(minutes=21) + device.last_seen = now + await db_session.flush() + + await service.check_session_timeouts(timeout_minutes=TIMEOUT, gap_minutes=GAP, now=now) + await db_session.flush() + + assert (await _session_row(db_session, session_id)).ended_at == moved_at diff --git a/backend/tests/unit/services/test_widget_aggregation.py b/backend/tests/unit/services/test_widget_aggregation.py index 73b96bd4..7bc104db 100644 --- a/backend/tests/unit/services/test_widget_aggregation.py +++ b/backend/tests/unit/services/test_widget_aggregation.py @@ -693,7 +693,12 @@ async def test_overdue_vs_upcoming_by_date_and_mileage(self, db_session, aggrega title="already done", reminder_type="date", due_date=past, - status="completed", + # 'done', not 'completed': check_reminder_status has always + # been (pending, done, dismissed) in the migrated schema, and + # nothing in the app writes "completed". This fixture only + # passed because the ORM declared no CHECK, so create_all + # databases accepted a value production rejects. + status="done", ), ] ) diff --git a/backend/tests/unit/test_maintenance_fail_closed.py b/backend/tests/unit/test_maintenance_fail_closed.py new file mode 100644 index 00000000..3d8a1c77 --- /dev/null +++ b/backend/tests/unit/test_maintenance_fail_closed.py @@ -0,0 +1,131 @@ +"""Maintenance mode must be fail-closed at the writer, not only at the route. + +The first implementation gated two URL prefixes. Review found the admin SD +backfill route open. That was fixed by matching the route exactly -- and review +then found `POST /api/livelink/mqtt/restart`, which writes no telemetry itself +but starts the MQTT subscriber, which does. + +That is two rounds of the same defect, and the shape is clear: any gate that +enumerates entry points is a floor. MQTT is not a route. The scheduler is not a +route. A future ingest path need not be a route either. + +So the gate moves to the place every path must pass through: the three writers +themselves. The route gate stays, because a 503 at the edge is a better answer +than a 500 from the middle, but it is no longer the thing being relied on. +""" + +from datetime import UTC, datetime + +import pytest + +from app.services.telemetry_service import MaintenanceModeError + + +@pytest.fixture +def maintenance_on(monkeypatch): + from app.config import settings + + monkeypatch.setattr(settings, "maintenance_mode", True) + + +@pytest.mark.asyncio +class TestWritersRefuseInMaintenanceMode: + """Every telemetry writer refuses, whatever called it.""" + + async def test_store_telemetry_refuses(self, db_session, maintenance_on): + from app.services.telemetry_service import TelemetryService + + with pytest.raises(MaintenanceModeError): + await TelemetryService(db_session).store_telemetry( + "1HGBH41JXMN109186", "dev-1", {"SPEED": 40}, {} + ) + + async def test_store_torque_telemetry_refuses(self, db_session, maintenance_on): + from app.services.telemetry_service import TelemetryService + + with pytest.raises(MaintenanceModeError): + await TelemetryService(db_session).store_torque_telemetry( + "1HGBH41JXMN109186", "dev-1", datetime.now(UTC), {"SPEED": 40} + ) + + async def test_bulk_backfill_refuses(self, db_session, maintenance_on): + """The SD path. This is the one an admin can trigger by hand.""" + from app.services.telemetry_service import TelemetryService + + with pytest.raises(MaintenanceModeError): + await TelemetryService(db_session).bulk_backfill("1HGBH41JXMN109186", "dev-1", []) + + async def test_bulk_backfill_refuses_before_it_looks_at_its_rows( + self, db_session, maintenance_on + ): + """An empty row list must not become an early return that skips the gate. + + Written because the natural implementation of `bulk_backfill` starts by + short-circuiting on empty input, which would make the test above pass + for the wrong reason and leave a non-empty backfill wide open. + """ + from app.services.telemetry_service import TelemetryService + + with pytest.raises(MaintenanceModeError): + await TelemetryService(db_session).bulk_backfill( + "1HGBH41JXMN109186", "dev-1", [object()] + ) + + +@pytest.mark.asyncio +class TestMqttCannotBeStartedInMaintenanceMode: + """`POST /api/livelink/mqtt/restart` is a route that writes no telemetry + and yet lets telemetry in. The gate belongs on the starter.""" + + async def test_start_is_refused(self, maintenance_on, monkeypatch): + from app.services.mqtt_subscriber import mqtt_subscriber + from app.tasks import livelink_tasks + + started = False + + async def _fake_start(): + nonlocal started + started = True + + monkeypatch.setattr(mqtt_subscriber, "start", _fake_start) + monkeypatch.setattr(livelink_tasks, "is_mqtt_enabled", lambda: _true()) + + await livelink_tasks.start_mqtt_subscriber() + assert started is False, "maintenance mode must not start the MQTT subscriber" + + async def test_restart_is_refused(self, maintenance_on, monkeypatch): + from app.services.mqtt_subscriber import mqtt_subscriber + from app.tasks import livelink_tasks + + started = False + + async def _fake_start(): + nonlocal started + started = True + + async def _fake_stop(): + return None + + monkeypatch.setattr(mqtt_subscriber, "start", _fake_start) + monkeypatch.setattr(mqtt_subscriber, "stop", _fake_stop) + monkeypatch.setattr(livelink_tasks, "is_mqtt_enabled", lambda: _true()) + + await livelink_tasks.restart_mqtt_subscriber() + assert started is False, "the admin restart route must not reopen ingest" + + +async def _true() -> bool: + return True + + +@pytest.mark.asyncio +class TestNormalOperationIsUnaffected: + """The gate must be off when maintenance mode is off. + + Without this, a mutation that always raises would pass every test above. + """ + + async def test_bulk_backfill_runs_when_not_in_maintenance(self, db_session): + from app.services.telemetry_service import TelemetryService + + assert await TelemetryService(db_session).bulk_backfill("V", "d", []) == 0 diff --git a/backend/tests/unit/test_maintenance_mode.py b/backend/tests/unit/test_maintenance_mode.py new file mode 100644 index 00000000..dc2312f2 --- /dev/null +++ b/backend/tests/unit/test_maintenance_mode.py @@ -0,0 +1,76 @@ +"""Maintenance mode gives the documented upgrade procedure a window to run in. + +The upgrade note tells operators to run the odometer repair tools "before the +upgraded instance records new readings". There was no way to do that. Migrations +run inside the app's own lifespan, `livelink_mqtt_enabled` is a database row +rather than an env var (so it cannot be turned off before the process that reads +it starts), and HTTP ingest serves the moment routers mount. A buffered WiCAN +replays its backlog within seconds of the broker connecting, which lands the +instance in exactly the half-converted state the tools then refuse to touch. + +`MYGARAGE_MAINTENANCE_MODE=1` starts the app far enough to run migrations, and +no further: no scheduler, no MQTT subscriber, and ingest answers 503. +""" + +import pytest + +INGEST_PATHS = [ + "/api/v1/livelink/ingest", + "/api/v1/torque/upload", +] + + +@pytest.mark.asyncio +class TestMaintenanceModeGate: + """Ingest is closed in maintenance mode and open outside it.""" + + @pytest.mark.parametrize("path", INGEST_PATHS) + async def test_ingest_is_refused_with_503(self, client, monkeypatch, path): + """503, not 404 or 401: the endpoint exists and is deliberately closed.""" + from app.config import settings + + monkeypatch.setattr(settings, "maintenance_mode", True) + + response = await client.post(path, json={}) + + assert response.status_code == 503, ( + f"{path} accepted a reading while the instance was in maintenance mode" + ) + assert "maintenance" in response.text.lower() + + @pytest.mark.parametrize("path", INGEST_PATHS) + async def test_ingest_is_not_gated_when_maintenance_mode_is_off( + self, client, monkeypatch, path + ): + """The complement. Without it, a gate that always fires would pass above. + + Any status but 503 proves the request reached real handling; an + unauthenticated empty POST is expected to be rejected on its own terms + (401/403/422), which is not this gate's doing. + """ + from app.config import settings + + monkeypatch.setattr(settings, "maintenance_mode", False) + + response = await client.post(path, json={}) + + assert response.status_code != 503, f"{path} was gated with maintenance mode off" + + async def test_health_still_answers_in_maintenance_mode(self, client, monkeypatch): + """The container's healthcheck must not fail the upgrade window.""" + from app.config import settings + + monkeypatch.setattr(settings, "maintenance_mode", True) + + response = await client.get("/api/health") + + assert response.status_code == 200 + + +class TestMaintenanceModeSetting: + """The flag is off unless explicitly set.""" + + def test_defaults_to_off(self): + from app.config import Settings + + assert Settings().maintenance_mode is False diff --git a/backend/tests/unit/test_maintenance_route_inventory.py b/backend/tests/unit/test_maintenance_route_inventory.py new file mode 100644 index 00000000..1b3e1d44 --- /dev/null +++ b/backend/tests/unit/test_maintenance_route_inventory.py @@ -0,0 +1,232 @@ +"""Maintenance mode must close every route that can write telemetry. + +The point of maintenance mode is that no new reading lands between migration +096 and the odometer repair tools; a single reading arriving mid-repair +recreates the mixed-unit state the tools refuse to run against. + +The first implementation closed two prefixes, `/api/v1/livelink` and +`/api/v1/torque`, chosen by looking at the ingest routers. That missed +`POST /api/livelink/devices/{device_id}/backfill`, which lives under the *admin* +prefix, reaches `bulk_backfill`, and is callable by any admin or any script +holding an admin token while the instance is supposedly quiescent. + +So this file does not check a hand-written list of paths. It derives the list +from the code: any route handler that calls a telemetry writer must be closed. +A new ingest route added later fails this test without anyone remembering to +update it, which a hand-written list would not do. +""" + +import ast +import re +from pathlib import Path + +import pytest + +from app.middleware import is_maintenance_closed + +#: Functions that write telemetry. A route reaching any of these must be closed. +#: Hand-written, and therefore a floor unless something checks it -- which is +#: what `TestTelemetryWriterSet` below does. +TELEMETRY_WRITERS = frozenset( + {"store_telemetry", "store_torque_telemetry", "bulk_backfill", "backfill_device"} +) + +#: Models whose construction means a telemetry row is being written. +TELEMETRY_MODELS = frozenset({"VehicleTelemetry"}) + +#: Functions that build a telemetry model but are unreachable from any route. +#: `store_value` currently has zero callers anywhere in `app/`. Listed rather +#: than silently excluded, so that giving it a caller fails this test. +KNOWN_UNREACHABLE = frozenset({"store_value"}) + +APP_DIR = Path(__file__).resolve().parents[2] / "app" +ROUTES_DIR = APP_DIR / "routes" + +#: Routers whose paths are relative to a prefix declared in the module. +_ROUTE_DECORATORS = frozenset({"get", "post", "put", "patch", "delete"}) + + +def _router_prefix(tree: ast.Module) -> str: + """The `prefix=` of the module's `APIRouter(...)`, or "" if it has none.""" + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + if node.func.id != "APIRouter": + continue + for kw in node.keywords: + if kw.arg == "prefix" and isinstance(kw.value, ast.Constant): + return str(kw.value.value) + return "" + + +def _called_names(node: ast.AST) -> set[str]: + """Every function name called anywhere inside `node`, by either call shape.""" + names: set[str] = set() + for sub in ast.walk(node): + if not isinstance(sub, ast.Call): + continue + if isinstance(sub.func, ast.Attribute): + names.add(sub.func.attr) + elif isinstance(sub.func, ast.Name): + names.add(sub.func.id) + return names + + +def _writing_routes() -> list[tuple[str, str]]: + """Every (path, source file) whose handler reaches a telemetry writer. + + Resolution is **transitive within the module**. The first version of this + scan looked only at direct calls in the handler body and therefore missed + `POST /api/v1/torque/{token}/upload`, whose writer call lives in a helper + the handler delegates to. A one-level scan silently under-reports, which in + a test that asserts "everything is closed" reads as everything passing. + """ + found: list[tuple[str, str]] = [] + for module in sorted(ROUTES_DIR.glob("*.py")): + tree = ast.parse(module.read_text()) + prefix = _router_prefix(tree) + + funcs = { + n.name: n + for n in ast.walk(tree) + if isinstance(n, ast.AsyncFunctionDef | ast.FunctionDef) + } + calls = {name: _called_names(node) for name, node in funcs.items()} + + # Fixed point: a function writes telemetry if it calls a writer, or + # calls something in this module that does. + writes = {name for name, c in calls.items() if c & TELEMETRY_WRITERS} + changed = True + while changed: + changed = False + for name, c in calls.items(): + if name not in writes and c & writes: + writes.add(name) + changed = True + + for name in writes: + for dec in funcs[name].decorator_list: + if ( + isinstance(dec, ast.Call) + and isinstance(dec.func, ast.Attribute) + and dec.func.attr in _ROUTE_DECORATORS + and dec.args + and isinstance(dec.args[0], ast.Constant) + ): + found.append((prefix + str(dec.args[0].value), module.name)) + return sorted(set(found)) + + +class TestMaintenanceRouteInventory: + """Derived from the code, not from a list someone remembered to update.""" + + def test_the_scan_finds_the_routes_it_is_supposed_to_find(self): + """Guards the guard: an AST walk that silently matches nothing would + make every assertion below vacuously true.""" + paths = {path for path, _ in _writing_routes()} + assert "/api/v1/livelink/ingest" in paths + # Transitive: the writer call is in a helper, not in the handler. + assert "/api/v1/torque/{token}/upload" in paths + # The route the first implementation of maintenance mode left open. + assert "/api/livelink/devices/{device_id}/backfill" in paths + + @pytest.mark.parametrize("path,module", _writing_routes(), ids=lambda v: str(v)) + def test_every_telemetry_writing_route_is_closed(self, path: str, module: str): + assert is_maintenance_closed(path), ( + f"{path} (in {module}) reaches a telemetry writer but maintenance mode " + f"lets it through, so a reading can land during the repair window." + ) + + +class TestIsMaintenanceClosed: + """The predicate itself, including what must stay open.""" + + @pytest.mark.parametrize( + "path", + [ + "/api/v1/livelink", + "/api/v1/torque", + "/api/livelink/devices/abc123/backfill", + ], + ) + def test_closed(self, path: str): + assert is_maintenance_closed(path) is True + + @pytest.mark.parametrize( + "path", + [ + # The operator needs the admin UI to watch the repair and to flip + # the flag back off. Closing the whole /api/livelink prefix would + # take it away. + "/api/livelink/devices", + "/api/livelink/devices/abc123", + "/api/livelink/settings", + "/api/vehicles", + "/health", + # Not a backfill route despite the substring. + "/api/livelink/devices/abc123/backfill-history", + ], + ) + def test_open(self, path: str): + assert is_maintenance_closed(path) is False + + +class TestTelemetryWriterSet: + """The writer set is itself an inventory, so it is itself a floor. + + Everything above assumes `TELEMETRY_WRITERS` names every function that can + write a telemetry row. Nothing checked that. This does: any function in + `app/services/` that constructs a telemetry model must either be a declared + writer, or be provably unreachable from a route. + """ + + @staticmethod + def _functions_building_telemetry() -> dict[str, str]: + """{function name: module} for every telemetry-model constructor.""" + found: dict[str, str] = {} + for module in sorted((APP_DIR / "services").glob("*.py")): + tree = ast.parse(module.read_text()) + for node in ast.walk(tree): + if not isinstance(node, ast.AsyncFunctionDef | ast.FunctionDef): + continue + built = { + sub.func.id + for sub in ast.walk(node) + if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name) + } + if built & TELEMETRY_MODELS: + found[node.name] = module.name + return found + + def test_the_scan_finds_the_known_writer(self): + """Guards the guard: a scan matching nothing makes the next test vacuous.""" + assert "store_telemetry" in self._functions_building_telemetry() + + def test_every_telemetry_builder_is_declared_or_unreachable(self): + builders = self._functions_building_telemetry() + undeclared = { + name: mod + for name, mod in builders.items() + if name not in TELEMETRY_WRITERS and name not in KNOWN_UNREACHABLE + } + assert not undeclared, ( + f"these build a telemetry row but are not in TELEMETRY_WRITERS: " + f"{undeclared}. Add them there (so the route scan sees them), or to " + f"KNOWN_UNREACHABLE if nothing calls them." + ) + + def test_the_unreachable_ones_really_are_unreachable(self): + """`KNOWN_UNREACHABLE` is an escape hatch, so it needs its own guard. + + Without this, listing a function here would be a way to silence the + test above rather than a statement of fact. + """ + sources = "\n".join( + f.read_text() for f in APP_DIR.rglob("*.py") if f.name != "telemetry_service.py" + ) + for name in KNOWN_UNREACHABLE: + calls = re.findall(rf"\b{re.escape(name)}\s*\(", sources) + assert not calls, ( + f"{name} is listed as unreachable but is now called. Either move " + f"it into TELEMETRY_WRITERS or remove it from KNOWN_UNREACHABLE." + ) diff --git a/backend/tests/unit/test_tier1_features.py b/backend/tests/unit/test_tier1_features.py index 0de96056..41d88b29 100644 --- a/backend/tests/unit/test_tier1_features.py +++ b/backend/tests/unit/test_tier1_features.py @@ -15,7 +15,8 @@ parse_fuelio, parse_tesla, ) -from app.services.tire_service import _project_wear +from app.services.tire_results import WearStatus +from app.services.tire_service import project_wear class _Reading: @@ -25,6 +26,29 @@ def __init__(self, recorded_at, odometer_km, tread_depth_mm): self.tread_depth_mm = tread_depth_mm +def _tire_with(readings, min_tread, *, bounded=True): + """A tire whose mount history supports (or does not support) a projection. + + v3.3.0 made the projection period-aware: the distance is the tire's own, + not the vehicle's odometer span between two readings. So these tests need + a tire with a bounded mount period, and `bounded=False` exercises the case + the release exists to stop publishing. + """ + from app.models.tire import Tire, TireMountPeriod + + # `readings` are plain stand-ins, not ORM instances, so they go through + # project_wear's explicit parameter rather than the relationship. + tire = Tire(vin="V" * 17, position="FL", min_tread_mm=min_tread) + tire.mount_periods = [ + TireMountPeriod( + position="FL", + mounted_on=date(2025, 1, 1), + mounted_odometer_km=Decimal("9000") if bounded else None, + ) + ] + return tire + + def test_parse_fuelio_metric(): csv_data = ( "Date,Odometer,Fuel Type,Volume(l),Price,Total cost,Full tank,Notes\n" @@ -87,15 +111,17 @@ def test_project_wear(): _Reading(date(2026, 6, 1), Decimal("12000"), Decimal("4.0")), _Reading(date(2026, 1, 1), Decimal("10000"), Decimal("6.0")), ] - km_left, wear_date = _project_wear(readings, Decimal("2.0")) - assert km_left is not None - assert km_left == Decimal("2000.0") - assert wear_date is not None + result = project_wear(_tire_with(readings, Decimal("2.0")), Decimal("12000"), readings) + assert result.status is WearStatus.PROJECTED + assert result.km_remaining == Decimal("2000.0") + assert result.wear_date is not None def test_project_wear_needs_two_readings(): readings = [_Reading(date(2026, 1, 1), Decimal("10000"), Decimal("6.0"))] - assert _project_wear(readings, Decimal("2.0")) == (None, None) + result = project_wear(_tire_with(readings, Decimal("2.0")), Decimal("12000"), readings) + assert result.status is WearStatus.INSUFFICIENT_READINGS + assert result.km_remaining is None def test_project_wear_already_below_threshold(): @@ -103,9 +129,31 @@ def test_project_wear_already_below_threshold(): _Reading(date(2026, 6, 1), Decimal("12000"), Decimal("1.5")), _Reading(date(2026, 1, 1), Decimal("10000"), Decimal("6.0")), ] - km_left, wear_date = _project_wear(readings, Decimal("2.0")) - assert km_left == Decimal("0") - assert wear_date == date(2026, 6, 1) + result = project_wear(_tire_with(readings, Decimal("2.0")), Decimal("12000"), readings) + assert result.status is WearStatus.AT_OR_BELOW_MINIMUM + assert result.km_remaining == Decimal("0") + assert result.wear_date == date(2026, 6, 1) + + +def test_project_wear_is_suppressed_without_a_bounded_mount_history(): + """The whole reason this release exists. + + With no odometer on the mount period, the only distance available is the + vehicle's raw odometer span -- which for a two-set owner counts the + kilometres driven on the OTHER set, and errs high. The number is withheld + rather than published with an "estimate" badge. + """ + readings = [ + _Reading(date(2026, 6, 1), Decimal("12000"), Decimal("4.0")), + _Reading(date(2026, 1, 1), Decimal("10000"), Decimal("6.0")), + ] + result = project_wear( + _tire_with(readings, Decimal("2.0"), bounded=False), + Decimal("12000"), + readings, + ) + assert result.status is WearStatus.UNVERIFIED_MOUNT_HISTORY + assert result.km_remaining is None def test_parse_fuel_command_metric(): diff --git a/backend/tests/unit/tools/test_tool_dates.py b/backend/tests/unit/tools/test_tool_dates.py new file mode 100644 index 00000000..6d81bd58 --- /dev/null +++ b/backend/tests/unit/tools/test_tool_dates.py @@ -0,0 +1,50 @@ +"""A repair tool must read a DATE column on either dialect. + +`backfill_livelink_odometer.py` called `date.fromisoformat(row.day)` on three +columns. SQLite has no date type, so `date(timestamp)` and a `DATE` column both +come back as `str` and the call is correct. psycopg2 adapts PostgreSQL `DATE` to +`datetime.date`, and `date.fromisoformat` rejects a `date`: + + TypeError: fromisoformat: argument must be str + +Before commit `96313be` this was unreachable, because the tool hardcoded +`sqlite:///{path}` and could never open a PostgreSQL database at all. Making the +tool reach PostgreSQL moved the failure from "connects to the wrong database" to +"dies on the first telemetry row", which is why the fix and this guard arrive +together rather than in the commit that added the URL resolver. +""" + +from datetime import date + +import pytest + +from tools._tool_db import as_date + + +class TestAsDate: + """Normalizes whatever the driver returns for a DATE column.""" + + def test_a_sqlite_string_is_parsed(self): + assert as_date("2026-04-25") == date(2026, 4, 25) + + def test_a_postgres_date_passes_through(self): + """The defect. psycopg2 returns `datetime.date`, not `str`.""" + assert as_date(date(2026, 4, 25)) == date(2026, 4, 25) + + def test_a_datetime_is_narrowed_to_its_date(self): + """`date(timestamp)` on PostgreSQL yields a date, but a raw timestamp + column selected without the cast yields a datetime. Narrow rather than + raise, because a datetime carries the answer.""" + from datetime import datetime + + assert as_date(datetime(2026, 4, 25, 13, 30)) == date(2026, 4, 25) + + def test_none_is_refused_rather_than_silently_dropped(self): + """A null day would otherwise become a dict key of None and group every + null row into one fabricated day.""" + with pytest.raises(ValueError, match="null"): + as_date(None) + + def test_an_unparseable_string_still_raises(self): + with pytest.raises(ValueError): + as_date("not-a-date") diff --git a/backend/tests/unit/tools/test_tool_db.py b/backend/tests/unit/tools/test_tool_db.py new file mode 100644 index 00000000..46a2f823 --- /dev/null +++ b/backend/tests/unit/tools/test_tool_db.py @@ -0,0 +1,55 @@ +"""`--db` must accept whatever database the instance actually runs. + +The three raw-SQL maintenance tools took `--db` as a filesystem path and built +`sqlite:///{path}` from it unconditionally. That is wrong two ways on a +PostgreSQL instance: there is no path to give, and any path given produces an +empty SQLite file rather than an error. + +`resolve_sync_url` keeps the old path form working, because the published +upgrade note tells people to pass `/data/mygarage.db`, and adds the two forms +that instance needs: a full URL, or nothing at all (fall back to the app's own +configured database). +""" + +import pytest + +from tools._tool_db import resolve_sync_url + + +class TestResolveSyncUrl: + """`--db` accepts a path, a URL, or nothing.""" + + def test_a_bare_path_is_still_sqlite(self): + """The published upgrade note passes `/data/mygarage.db`; it must keep working.""" + assert resolve_sync_url("/data/mygarage.db") == "sqlite:////data/mygarage.db" + + def test_a_relative_path_is_still_sqlite(self): + assert resolve_sync_url("mygarage.db") == "sqlite:///mygarage.db" + + def test_a_postgres_url_stays_postgres(self): + """The defect. A path-only reading of this produced a SQLite file.""" + assert ( + resolve_sync_url("postgresql+asyncpg://u:p@host/mygarage") + == "postgresql+psycopg2://u:p@host/mygarage" + ) + + def test_a_sqlite_url_is_accepted_as_a_url(self): + assert resolve_sync_url("sqlite:////data/mygarage.db") == "sqlite:////data/mygarage.db" + + def test_no_argument_falls_back_to_the_configured_database(self, monkeypatch): + """A tool run inside the container should not need to be told where the DB is.""" + import app.config + + monkeypatch.setattr( + app.config.settings, "database_url", "postgresql+asyncpg://u:p@host/mygarage" + ) + assert resolve_sync_url(None) == "postgresql+psycopg2://u:p@host/mygarage" + + def test_a_windows_style_path_is_not_mistaken_for_a_url(self): + """`C:\\db` contains a colon but is not a scheme. Guarded deliberately.""" + assert resolve_sync_url("C:/data/mygarage.db") == "sqlite:///C:/data/mygarage.db" + + def test_an_unreachable_dialect_is_refused_by_name(self): + """A URL naming a driver the tools cannot use fails here, not mid-migration.""" + with pytest.raises(ValueError, match="mysql"): + resolve_sync_url("mysql://u:p@host/mygarage") diff --git a/backend/tests/unit/tools/test_tool_exit_codes.py b/backend/tests/unit/tools/test_tool_exit_codes.py new file mode 100644 index 00000000..557c34a4 --- /dev/null +++ b/backend/tests/unit/tools/test_tool_exit_codes.py @@ -0,0 +1,127 @@ +"""A dry run that found unconvertible data must not report success. + +Both converters refuse a device whose odometer history mixes miles and +kilometres, because neither converting nor skipping is safe. They signal that +refusal with exit 2, and the upgrade note tells operators to run each tool as a +dry run first. + +`normalize_telemetry_odometer_units.py` returned 0 from its dry-run branch +regardless, so a script gating `--apply` on the dry run's exit status would +greenlight the apply the dry run had just refused. +`fix_session_odometer_units.py` already returns `2 if mixed_devices else 0` +there; this pins both, so the correct one cannot regress to match the wrong one. +""" + +import sqlite3 + +import pytest + +_MIXED = (1000.0, 1609.0) # ratio 1.609: a units discontinuity, inside [1.55, 1.67] + + +def _seed(db_path: str) -> None: + """Two 'mi' devices: one with mixed history, one plainly convertible. + + Both are needed. With only the mixed device the plan is empty and the tool + returns through its `if not plan` branch, which was already correct. The + convertible device is what carries execution into the dry-run branch that + was not. + """ + conn = sqlite3.connect(db_path) + conn.executescript(""" + CREATE TABLE livelink_devices ( + device_id TEXT, vin TEXT, odometer_unit TEXT, kind TEXT); + CREATE TABLE vehicle_telemetry ( + vin TEXT, device_id TEXT, param_key TEXT, value REAL, timestamp TEXT); + CREATE TABLE vehicle_telemetry_latest (vin TEXT, param_key TEXT, value REAL); + CREATE TABLE odometer_records (vin TEXT, date TEXT, odometer_km REAL); + CREATE TABLE drive_sessions ( + device_id TEXT, started_at TEXT, start_odometer REAL, + end_odometer REAL, distance_km REAL); + """) + conn.executemany( + "INSERT INTO livelink_devices VALUES (?,?,?,?)", + [("devmixed", "VINMIXED", "mi", "wican"), ("devplain", "VINPLAIN", "mi", "wican")], + ) + conn.executemany( + "INSERT INTO vehicle_telemetry VALUES (?,?,?,?,?)", + [ + ("VINMIXED", "devmixed", "ODOMETER", _MIXED[0], "2026-09-01 10:00:00"), + ("VINMIXED", "devmixed", "ODOMETER", _MIXED[1], "2026-09-01 10:01:00"), + ("VINPLAIN", "devplain", "ODOMETER", 1000.0, "2026-09-01 10:00:00"), + ("VINPLAIN", "devplain", "ODOMETER", 1001.0, "2026-09-01 10:01:00"), + ], + ) + conn.executemany( + "INSERT INTO vehicle_telemetry_latest VALUES (?,?,?)", + [("VINMIXED", "ODOMETER", _MIXED[1]), ("VINPLAIN", "ODOMETER", 1001.0)], + ) + # devplain's km records tower over its stored miles by the miles factor, + # which is what marks it as still needing conversion. + conn.executemany( + "INSERT INTO odometer_records VALUES (?,?,?)", + [("VINPLAIN", "2026-09-01", 1610.0), ("VINMIXED", "2026-09-01", 1610.0)], + ) + conn.executemany( + "INSERT INTO drive_sessions VALUES (?,?,?,?,?)", + [ + ("devmixed", "2026-09-01 10:00:00", _MIXED[0], _MIXED[0], 0.0), + ("devmixed", "2026-09-01 11:00:00", _MIXED[1], _MIXED[1], 0.0), + ("devplain", "2026-09-01 10:00:00", 1000.0, 1001.0, 1.0), + ], + ) + conn.commit() + conn.close() + + +@pytest.fixture +def seeded_db(tmp_path): + path = tmp_path / "mygarage.db" + _seed(str(path)) + return str(path) + + +@pytest.mark.parametrize( + "module_name", + [ + "tools.normalize_telemetry_odometer_units", + "tools.fix_session_odometer_units", + ], +) +def test_dry_run_exits_2_when_it_refused_a_mixed_device(module_name, seeded_db, monkeypatch): + """The refusal must reach the caller's exit status, not only the console.""" + import importlib + + module = importlib.import_module(module_name) + monkeypatch.setattr("sys.argv", [module_name, "--db", seeded_db]) + + assert module.main() == 2, ( + f"{module_name} reported success from a dry run that refused a mixed-unit device" + ) + + +@pytest.mark.parametrize( + "module_name", + [ + "tools.normalize_telemetry_odometer_units", + "tools.fix_session_odometer_units", + ], +) +def test_dry_run_exits_0_when_nothing_was_refused(module_name, tmp_path, monkeypatch): + """The complement, so exit 2 means 'refused' rather than 'ran'.""" + import importlib + import sqlite3 as s3 + + path = str(tmp_path / "clean.db") + _seed(path) + # Remove the mixed device entirely; the convertible one remains. + conn = s3.connect(path) + for table in ("livelink_devices", "vehicle_telemetry", "drive_sessions"): + conn.execute(f"DELETE FROM {table} WHERE device_id = 'devmixed'") + conn.commit() + conn.close() + + module = importlib.import_module(module_name) + monkeypatch.setattr("sys.argv", [module_name, "--db", path]) + + assert module.main() == 0 diff --git a/backend/tests/unit/utils/test_csv_emission.py b/backend/tests/unit/utils/test_csv_emission.py index 338807fe..e6ce134d 100644 --- a/backend/tests/unit/utils/test_csv_emission.py +++ b/backend/tests/unit/utils/test_csv_emission.py @@ -36,6 +36,7 @@ FUEL_SPEED, FUEL_TEMPERATURE, FUEL_VOLUME, + MILEAGE_LIMIT_DISTANCE, ODOMETER_DISTANCE, READING_DISTANCE, QuantitySpec, @@ -355,6 +356,7 @@ class TestEmissionMeetsTheImporter: SPECS: dict[str, tuple[QuantitySpec, str]] = { "Odometer (km)": (ODOMETER_DISTANCE, "distance"), "Reading (km)": (READING_DISTANCE, "distance"), + "Mileage Limit (km)": (MILEAGE_LIMIT_DISTANCE, "distance"), "Liters": (FUEL_VOLUME, "volume"), "Price Per Liter": (FUEL_PRICE, "price_per_volume"), "Price Per Unit": (DEF_PRICE, "price_per_volume"), diff --git a/backend/tests/unit/utils/test_db_url.py b/backend/tests/unit/utils/test_db_url.py new file mode 100644 index 00000000..9e4985bd --- /dev/null +++ b/backend/tests/unit/utils/test_db_url.py @@ -0,0 +1,57 @@ +"""The maintenance tools must reach whichever database the instance actually uses. + +`backfill_livelink_odometer.py`, `normalize_telemetry_odometer_units.py` and +`fix_session_odometer_units.py` each built their engine as +`create_engine(f"sqlite:///{args.db}")`. On a PostgreSQL instance that does not +fail loudly: SQLAlchemy happily creates an **empty SQLite file** at the given +path, and the tool then dies with `no such table: livelink_devices`. PostgreSQL +is a supported, CI-tested deployment, so every PostgreSQL instance hit by the +v2.26.2 odometer-units regression had no repair path at all. + +The conversion these tools need already existed, written inline in +`init_db` (`app/database.py`), which is why it is extracted here rather than +written a second time. +""" + +import pytest + +from app.utils.db_url import to_sync_url + + +class TestToSyncUrl: + """Async driver URLs convert to the sync driver the tools and migrations use.""" + + def test_asyncpg_becomes_psycopg2(self): + """PostgreSQL is the case the tools got wrong; it must survive intact.""" + assert ( + to_sync_url("postgresql+asyncpg://u:p@host:5432/mygarage") + == "postgresql+psycopg2://u:p@host:5432/mygarage" + ) + + def test_aiosqlite_becomes_sqlite(self): + assert to_sync_url("sqlite+aiosqlite:////data/mygarage.db") == "sqlite:////data/mygarage.db" + + def test_an_already_sync_url_is_unchanged(self): + """Idempotent: running it twice must not corrupt the URL.""" + assert to_sync_url("postgresql+psycopg2://u:p@h/db") == "postgresql+psycopg2://u:p@h/db" + assert to_sync_url("sqlite:////data/mygarage.db") == "sqlite:////data/mygarage.db" + + def test_a_postgres_url_never_becomes_sqlite(self): + """The exact defect: a PostgreSQL instance silently getting a SQLite file. + + Asserted as its own case rather than left implicit in the conversion + test, because this is the property that mattered and the one a future + refactor could quietly break. + """ + for url in ( + "postgresql+asyncpg://u:p@host/mygarage", + "postgresql://u:p@host/mygarage", + "postgresql+psycopg2://u:p@host/mygarage", + ): + assert not to_sync_url(url).startswith("sqlite"), url + + @pytest.mark.parametrize("url", ["", " "]) + def test_an_empty_url_is_rejected(self, url): + """An empty URL would otherwise reach create_engine as a mystery failure.""" + with pytest.raises(ValueError, match="empty"): + to_sync_url(url) diff --git a/backend/tests/unit/utils/test_distance_counters.py b/backend/tests/unit/utils/test_distance_counters.py new file mode 100644 index 00000000..818a22d6 --- /dev/null +++ b/backend/tests/unit/utils/test_distance_counters.py @@ -0,0 +1,189 @@ +"""Which telemetry keys measure distance, and which of them describes a drive best. + +The hardware measurements that motivate this, and the reason an odometer and a +distance counter are kept as disjoint sets, are argued in the module under test. +Tested here: that the classification holds at its edges (an unprefixed autopid, +a subset counter, an odometer) and that the selection arithmetic is right, both +without a database. +""" + +import pytest + +from app.utils.distance_counters import ( + is_distance_counter_param_key, + is_distance_source_param_key, + measure_travelled, + select_distance_source, +) + + +class TestIsDistanceCounterParamKey: + """Standard cumulative-distance PIDs, and nothing that merely reads like one.""" + + @pytest.mark.parametrize( + "key", + [ + "31-DISTANCESINCECODECLEAR", + "31-distancesincecodeclear", + ], + ) + def test_standard_distance_pids_are_counters(self, key): + assert is_distance_counter_param_key(key) is True + + @pytest.mark.parametrize( + "key", + [ + "DISTANCESINCECODECLEAR", + "distancesincecodeclear", + ], + ) + def test_an_unprefixed_name_is_refused(self, key): + """No PID prefix means an autopid, and an autopid's units are unknowable. + + The odometer survives the same ambiguity only because + `LiveLinkDevice.odometer_unit` lets a device declare its units. Nothing + declares units for a distance counter, so the ambiguous case is dropped + instead of assumed metric. + """ + assert is_distance_counter_param_key(key) is False + + @pytest.mark.parametrize( + "key", + [ + "A6-ODOMETER", + "ODOMETER", + "0D-VEHICLESPEED", + "0C-ENGINERPM", + "31-DISTANCE", + "DISTANCE_TO_EMPTY", + "", + ], + ) + def test_non_counters_are_refused(self, key): + assert is_distance_counter_param_key(key) is False + + def test_distance_with_the_mil_on_is_refused(self): + """PID 0x21 is metric, standard, and 1 km resolution, and still wrong here. + + It counts only the distance driven with the malfunction light lit, so it + measures a subset of the journey and starts mid-drive when a fault + appears. On a vehicle whose odometer does not tick within a drive it + would out-resolve the odometer, win the selection, and report the 5 km + since the light came on as the length of a 12 km trip. + """ + assert is_distance_counter_param_key("21-DISTANCEMILON") is False + assert is_distance_source_param_key("21-DISTANCEMILON") is False + + def test_an_odometer_is_not_a_distance_counter(self): + """They are different quantities and only one may stamp the odometer columns. + + `31-DISTANCESINCECODECLEAR` resets when a technician clears a code, so + writing it into `start_odometer` would report a vehicle with 3,000 km on + it. The two sets stay disjoint so that mistake needs a deliberate edit. + """ + assert is_distance_counter_param_key("A6-ODOMETER") is False + assert is_distance_counter_param_key("ODOMETER") is False + + +class TestIsDistanceSourceParamKey: + """The union: anything whose increase measures distance travelled.""" + + @pytest.mark.parametrize( + "key", + ["A6-ODOMETER", "ODOMETER", "ODO", "MILEAGE", "31-DISTANCESINCECODECLEAR"], + ) + def test_odometers_and_counters_are_both_sources(self, key): + assert is_distance_source_param_key(key) is True + + @pytest.mark.parametrize( + "key", ["0D-VEHICLESPEED", "2F-FUELTANKLEVEL", "DISTANCESINCECODECLEAR"] + ) + def test_everything_else_is_not(self, key): + assert is_distance_source_param_key(key) is False + + +class TestMeasureTravelled: + """Reducing one source's readings to what they say about a drive.""" + + def test_a_monotonic_source_sums_to_its_span(self): + """The odometer case, unchanged by any of this. + + Sum-of-rises and `max - min` are identical for a source that only ever + increases, which is what lets the same function serve both without the + odometer computing anything different from what it always did. + """ + span = measure_travelled([9195.0, 9197.0, 9203.0, 9210.0]) + + assert span.distance_km == pytest.approx(15.0) + assert span.high - span.low == pytest.approx(span.distance_km) + assert span.steps == 3 + + def test_a_reset_counts_only_the_rises(self): + """A code clear mid-window. `max - min` would read 806 on a 15 km drive.""" + span = measure_travelled([800.0, 810.0, 4.0, 9.0]) + + assert span.distance_km == pytest.approx(15.0) + assert span.high - span.low == pytest.approx(806.0), "which is why span is not distance" + assert span.steps == 2, "the reset itself is a fall, so it is not a step" + + def test_one_reading_is_no_distance_rather_than_no_answer(self): + """A single sample proves the vehicle was somewhere, never that it moved.""" + span = measure_travelled([141300.0]) + + assert span.steps == 0 + assert span.distance_km == 0.0 + assert span.low == span.high == 141300.0 + + def test_a_flat_source_reports_zero(self): + """The Mirage's odometer across a trip shorter than one of its steps.""" + span = measure_travelled([141300.0, 141300.0, 141300.0]) + + assert span.steps == 0 + assert span.distance_km == 0.0 + + +class TestSelectDistanceSource: + """Which source describes the window most finely.""" + + def test_more_steps_wins(self): + spans = { + "ODOMETER": measure_travelled([141300.0, 141300.0]), + "31-DISTANCESINCECODECLEAR": measure_travelled([500.0, 504.0, 509.0, 512.0]), + } + + assert select_distance_source(spans, {"ODOMETER"}) == "31-DISTANCESINCECODECLEAR" + + def test_an_odometer_wins_its_own_ties(self): + """What keeps the change additive. + + Equal resolution means the odometer keeps the job, so a device that was + already being measured correctly is never quietly restated. + """ + spans = { + "A6-ODOMETER": measure_travelled([9195.0, 9200.0, 9205.0]), + "31-DISTANCESINCECODECLEAR": measure_travelled([100.0, 108.0, 118.0]), + } + + assert select_distance_source(spans, {"A6-ODOMETER"}) == "A6-ODOMETER" + + def test_a_counter_wins_only_by_resolving_strictly_finer(self): + """One more step is enough, and one fewer is not.""" + odo = measure_travelled([9195.0, 9200.0, 9205.0]) + assert ( + select_distance_source( + {"A6-ODOMETER": odo, "31-X": measure_travelled([1.0, 2.0, 3.0, 4.0])}, + {"A6-ODOMETER"}, + ) + == "31-X" + ) + assert ( + select_distance_source( + {"A6-ODOMETER": odo, "31-X": measure_travelled([1.0, 2.0])}, + {"A6-ODOMETER"}, + ) + == "A6-ODOMETER" + ) + + def test_nothing_measured_selects_nothing(self): + """So the caller can fall through to the GPS breadcrumb.""" + assert select_distance_source({}, set()) is None diff --git a/backend/tests/unit/utils/test_export_column_classification.py b/backend/tests/unit/utils/test_export_column_classification.py index c5abb32b..aaf28a8f 100644 --- a/backend/tests/unit/utils/test_export_column_classification.py +++ b/backend/tests/unit/utils/test_export_column_classification.py @@ -61,8 +61,8 @@ # added to one of these is a real hazard too, and a worse one, because # there is no conversion layer to have missed. "export_hours_records_csv": 4, - "export_warranties_csv": 10, - "export_insurance_csv": 9, + "export_warranties_csv": 8, + "export_insurance_csv": 10, "export_tax_records_csv": 5, "export_notes_csv": 3, } @@ -107,16 +107,18 @@ "Provider", "Policy Number", "Type", - "Coverage", + # v3.3.0 renamed `Coverage` to `Coverage Details`, the actual model + # attribute, and dropped `Terms` and `Max Claims`, which had no model + # field at all and made the warranty export 500 on every row. + "Coverage Details", "Coverage Limits", - "Terms", + "Premium Frequency", "Title", "Content", # Booleans and counts. "Full Tank", "Missed Fill-up", "Is Hauling", - "Max Claims", # Percentages and ratios. Unitless by definition. "SOC Start (%)", "SOC End (%)", diff --git a/backend/tests/unit/utils/test_movement_keys.py b/backend/tests/unit/utils/test_movement_keys.py new file mode 100644 index 00000000..2af2b153 --- /dev/null +++ b/backend/tests/unit/utils/test_movement_keys.py @@ -0,0 +1,155 @@ +"""Every spelling of speed and RPM that can open a drive session. + +This exists because the design's first revision proposed reusing +``is_odometer_param_key``'s prefix-stripping treatment for these keys. That +works for odometer by coincidence -- ``A6-ODOMETER`` strips to ``ODOMETER``, +which is the bare name -- and does not hold here: ``0D-VEHICLESPEED`` strips to +``VEHICLESPEED``, not ``SPEED``. + +The consequence would have been silent and total. A standard-PID WiCAN emits +only prefixed keys, so every one of its readings would have answered "not a +speed key", no movement would ever be confirmed, and the device would record +zero sessions with nothing in the log to say why. "No movement detected" and +"key not recognised" are indistinguishable from outside, which is what makes +this worth a table rather than a spot check. + +Each spelling below is one that a real ingest path produces, named with the +path, so a future edit that drops one has to argue with the source rather than +with a list. +""" + +from __future__ import annotations + +import pytest + +from app.utils.movement_keys import is_rpm_param_key, is_speed_param_key + +#: (key, path that produces it) +SPEED_KEYS = [ + ("SPEED", "WiCAN bare autopid, and torque_pid_map's k0d target"), + ("0D-VehicleSpeed", "WiCAN standard-PID firmware, mixed case on the wire"), + ("0D-VEHICLESPEED", "the same key after canonical_param_key uppercases it"), + ("VEHICLE_SPEED", "canonical form of a 'Vehicle Speed' autopid"), +] + +RPM_KEYS = [ + ("RPM", "WiCAN bare autopid"), + ("ENGINE_RPM", "torque_pid_map's k0c target, and the canonical form"), + ("0C-EngineRPM", "WiCAN standard-PID firmware"), + ("0C-ENGINERPM", "the same key canonicalised"), +] + +#: Keys that must NOT match. The odometer and trip counters matter most: a +#: loose match there would let a parked heartbeat's odometer reading count as +#: movement, which is the exact defect the movement predicate exists to fix. +NON_MOVEMENT_KEYS = [ + "BATTERY_VOLTAGE", + "A6-ODOMETER", + "ODOMETER", + "21-DISTANCEMILON", + "COOLANT_TMP", + "THROTTLE", + "FUEL", + "SPEEDOMETER_CALIBRATION", + "MAX_SPEED_LIMIT", +] + + +@pytest.mark.parametrize("key,source", SPEED_KEYS, ids=[k for k, _ in SPEED_KEYS]) +def test_speed_spellings_are_recognised(key: str, source: str): + assert is_speed_param_key(key), f"{key} is emitted by {source}" + + +@pytest.mark.parametrize("key,source", RPM_KEYS, ids=[k for k, _ in RPM_KEYS]) +def test_rpm_spellings_are_recognised(key: str, source: str): + assert is_rpm_param_key(key), f"{key} is emitted by {source}" + + +@pytest.mark.parametrize("key", NON_MOVEMENT_KEYS) +def test_non_movement_keys_are_rejected_by_both(key: str): + assert not is_speed_param_key(key) + assert not is_rpm_param_key(key) + + +def test_speed_and_rpm_do_not_overlap(): + """A key that answered yes to both would double-count one reading.""" + for key, _ in SPEED_KEYS: + assert not is_rpm_param_key(key) + for key, _ in RPM_KEYS: + assert not is_speed_param_key(key) + + +def test_the_prefix_strip_alone_would_not_have_worked(): + """The specific mistake this module exists to avoid, pinned. + + ``is_odometer_param_key``'s treatment is: strip the PID prefix, compare + against the bare name. Applied to speed that yields ``VEHICLESPEED``, which + is not ``SPEED``. This asserts the alias set covers the stripped form, so + an edit that trims the sets back to the bare names fails here rather than + in production six weeks later. + """ + from app.utils.odometer_units import OBD2_PID_PREFIX_RE + + assert OBD2_PID_PREFIX_RE.sub("", "0D-VEHICLESPEED") == "VEHICLESPEED" + assert OBD2_PID_PREFIX_RE.sub("", "0C-ENGINERPM") == "ENGINERPM" + # ...and both of those must still resolve, which is the whole point. + assert is_speed_param_key("0D-VEHICLESPEED") + assert is_rpm_param_key("0C-ENGINERPM") + + +class TestTheSqlListsAgreeWithThePredicates: + """A key that can open a session must be a key the aggregates can read. + + The predicates run in Python, one key at a time. The aggregate reader runs + in SQL over an ``IN`` list, because grouping in Python would mean loading + every telemetry row of the window. Two representations of the same set is + exactly the shape that drifts: a key added to the predicate but not the + list opens sessions whose ``avg_speed`` and ``max_speed`` are then always + NULL, and nothing anywhere reports a mismatch. + + So the list is DERIVED from the predicate's alias set rather than written + beside it, and these tests pin that it stays derived. + """ + + def test_every_recognised_speed_spelling_is_in_the_sql_list(self): + from app.utils.movement_keys import speed_param_key_candidates + + candidates = {k.upper() for k in speed_param_key_candidates()} + for key, source in SPEED_KEYS: + assert key.upper() in candidates, f"{key} ({source}) opens a session but has no stats" + + def test_every_recognised_rpm_spelling_is_in_the_sql_list(self): + from app.utils.movement_keys import rpm_param_key_candidates + + candidates = {k.upper() for k in rpm_param_key_candidates()} + for key, source in RPM_KEYS: + assert key.upper() in candidates, f"{key} ({source}) opens a session but has no stats" + + def test_every_sql_candidate_is_accepted_by_its_predicate(self): + """The other direction, so the list cannot grow a key the predicate rejects.""" + from app.utils.movement_keys import rpm_param_key_candidates, speed_param_key_candidates + + for key in speed_param_key_candidates(): + assert is_speed_param_key(key), key + for key in rpm_param_key_candidates(): + assert is_rpm_param_key(key), key + + def test_the_candidate_lists_are_not_empty(self): + """Guard-the-guard: both tests above pass vacuously against an empty list.""" + from app.utils.movement_keys import rpm_param_key_candidates, speed_param_key_candidates + + assert "SPEED" in {k.upper() for k in speed_param_key_candidates()} + assert "0C-ENGINERPM" in {k.upper() for k in rpm_param_key_candidates()} + + def test_the_session_service_reads_its_lists_from_here(self): + """`SessionService` had its own hand-written copies of both lists. + + The RPM one was inline in `_calculate_session_aggregates`, twelve lines + from a SPEED list that was a module constant, and neither knew about + the other. This asserts both now come from the shared helper. + """ + from app.services.session_service import RPM_PARAM_KEYS, SPEED_PARAM_KEYS + from app.utils.movement_keys import rpm_param_key_candidates, speed_param_key_candidates + + assert set(SPEED_PARAM_KEYS) == set(speed_param_key_candidates()) + assert set(RPM_PARAM_KEYS) == set(rpm_param_key_candidates()) diff --git a/backend/tests/unit/utils/test_odometer_sync.py b/backend/tests/unit/utils/test_odometer_sync.py index 52f40a17..20af0669 100644 --- a/backend/tests/unit/utils/test_odometer_sync.py +++ b/backend/tests/unit/utils/test_odometer_sync.py @@ -6,6 +6,7 @@ from datetime import date from decimal import Decimal +from pathlib import Path import pytest import pytest_asyncio @@ -36,6 +37,59 @@ async def clean_odometer_records(db_session: AsyncSession, test_vehicle): await db_session.commit() +#: The `source_id` values these tests pass for `source_type="fuel"`. Enumerated +#: rather than guessed: `test_the_fixture_covers_every_fuel_source_id` reads +#: them back out of this file and fails if one is missing. +FUEL_SOURCE_IDS = (1, 2, 42, 99) + + +@pytest_asyncio.fixture(autouse=True) +async def fuel_records_the_tests_reference(db_session: AsyncSession, test_vehicle): + """Create the `fuel_records` rows these tests point `fuel_record_id` at. + + `odometer_records.fuel_record_id` carries a real foreign key. PostgreSQL + enforces it and rejected every sync from a fuel record here, because the + tests passed bare integers with no matching row; SQLite does not enforce FKs + without `PRAGMA foreign_keys=ON`, which the model's own comment notes, so + the whole file passed there and had never been run against PostgreSQL -- + CI runs only tests/migrations and tests/integration under it. + + Worse than a straight failure: the rejected flush poisoned the shared + session, so the next tests in the file failed with PendingRollbackError and + pointed at themselves rather than at this. + """ + from app.models.fuel import FuelRecord + + vin = test_vehicle["vin"] + for record_id in FUEL_SOURCE_IDS: + existing = await db_session.get(FuelRecord, record_id) + if existing is None: + db_session.add(FuelRecord(id=record_id, vin=vin, date=date(2024, 1, 1))) + await db_session.commit() + + yield + + await db_session.execute(delete(FuelRecord).where(FuelRecord.id.in_(FUEL_SOURCE_IDS))) + await db_session.commit() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_the_fixture_covers_every_fuel_source_id(): + """Guard on the fixture. A test added with a new fuel `source_id` would + fail on PostgreSQL only, in a file nothing runs there.""" + import re + + source = Path(__file__).read_text() + used = { + int(m.group(1)) for m in re.finditer(r'source_type="fuel",\s*\n\s*source_id=(\d+)', source) + } + assert used, "the scan found no fuel source ids, so this guard is vacuous" + assert used <= set(FUEL_SOURCE_IDS), ( + f"these fuel source_ids have no fuel_records row: {sorted(used - set(FUEL_SOURCE_IDS))}" + ) + + @pytest.mark.unit @pytest.mark.asyncio class TestOdometerSync: diff --git a/backend/tests/unit/utils/test_pdf_has_no_tire_section.py b/backend/tests/unit/utils/test_pdf_has_no_tire_section.py new file mode 100644 index 00000000..0c9eb019 --- /dev/null +++ b/backend/tests/unit/utils/test_pdf_has_no_tire_section.py @@ -0,0 +1,85 @@ +"""The analytics PDF has no tire section, on purpose, and this pins that. + +Spec B ships tire analytics to the PAGE only. The reason is upgrade day: after +migration 097 most tires answer `nothing_bounded`, so an exported tire section +would be a page of blanks in a document people archive and re-read months +later, long after the on-screen prompts that explain the blanks are gone. + +"Either the PDF has it or it does not" is not a decision: two implementations +would satisfy it and no test could tell them apart, which is how a page and its +own export come to disagree. This asserts the choice, so adding a tire section +later is a deliberate act that has to delete a test rather than a drift nobody +notices. + +It reads the headings MECHANICALLY, from the one call the module uses to emit +them, rather than trusting a hand-written list of section names to have stayed +current. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + +_PDF_MODULES = ( + Path("app/utils/pdf_vehicle_report.py"), + Path("app/utils/pdf_garage_report.py"), +) + +#: Every quantity the tire surface names. Deliberately wider than "tire": a +#: section called "Tread depth" or "Wear projection" would be the same +#: omission, and a grep for the word "tire" alone would not see it. +_TIRE_WORDS = re.compile(r"\btire|\btread|\bwear\b|\brotation\b", re.IGNORECASE) + + +def _section_headers(source: str) -> list[str]: + """Every literal passed to `make_section_header` in a module. + + Guard-the-guard: `test_the_enumerator_finds_a_known_section` asserts this + returns a heading that is definitely there, so a refactor that renames the + helper cannot quietly turn this file into a test of nothing. + """ + headers: list[str] = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Call): + continue + func = node.func + name = func.id if isinstance(func, ast.Name) else getattr(func, "attr", None) + if name != "make_section_header": + continue + for arg in node.args: + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + headers.append(arg.value) + elif isinstance(arg, ast.JoinedStr): + headers.extend( + v.value + for v in arg.values + if isinstance(v, ast.Constant) and isinstance(v.value, str) + ) + return headers + + +@pytest.mark.parametrize("module", _PDF_MODULES, ids=lambda p: p.name) +def test_no_pdf_section_is_about_tires(module: Path): + offenders = [h for h in _section_headers(module.read_text()) if _TIRE_WORDS.search(h)] + assert offenders == [], ( + f"{module.name} grew a tire section: {offenders}. Tire analytics are " + "page-only for v3.3.0 (spec B). If that decision has changed, delete " + "this test in the same commit that adds the section, and update the " + "changelog line that tells users the PDF does not include them." + ) + + +def test_the_enumerator_finds_a_known_section(): + """The guard on the guard. + + Without this, renaming `make_section_header` would make `_section_headers` + return nothing, every assertion above would pass vacuously, and a tire + section could be added to the PDF with the test that forbids it still + green. + """ + headers = _section_headers(_PDF_MODULES[0].read_text()) + assert "Cost Projections" in headers, headers diff --git a/backend/tools/_tool_db.py b/backend/tools/_tool_db.py new file mode 100644 index 00000000..ffaf0cab --- /dev/null +++ b/backend/tools/_tool_db.py @@ -0,0 +1,98 @@ +"""Resolve which database a maintenance tool should operate on. + +Shared by the raw-SQL repair tools. Each of them previously did + + engine = create_engine(f"sqlite:///{args.db}") + +which is wrong on a PostgreSQL instance in the worst way: SQLAlchemy creates an +empty SQLite file at the given path and the tool then fails with +`no such table: livelink_devices`, so the operator sees a missing-table error +rather than "this tool cannot reach your database". PostgreSQL is a supported, +CI-tested deployment, so those instances had no repair path at all. + +`--db` now accepts three forms: + +* a **path** (`/data/mygarage.db`) -- the form the published upgrade note uses, + kept working deliberately; +* a **URL** (`postgresql+asyncpg://...`), converted to the sync driver; +* **omitted**, falling back to the app's own configured database, which is the + right answer when the tool runs inside the container. +""" + +from __future__ import annotations + +import re +from datetime import date, datetime + +from app.utils.db_url import to_sync_url + +#: A URL scheme: letters, digits, +/-/. then "://". Requiring the slashes keeps +#: a Windows-style path ("C:/data/mygarage.db") from being read as a scheme. +_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://") + +#: Dialects the tools' raw SQL is written against. Anything else is refused by +#: name rather than failing later inside a statement. +_SUPPORTED_DIALECTS = ("sqlite", "postgresql") + + +def resolve_sync_url(db_arg: str | None) -> str: + """Return the sync SQLAlchemy URL a tool should connect with. + + Args: + db_arg: The value of ``--db``: a filesystem path, a SQLAlchemy URL, or + None to use the app's configured database. + + Returns: + A sync-driver SQLAlchemy URL. + + Raises: + ValueError: If the resolved URL names a dialect these tools do not + support, or if the configured database URL is empty. + """ + if db_arg is None: + from app.config import settings + + url = to_sync_url(settings.database_url) + elif _URL_SCHEME_RE.match(db_arg): + url = to_sync_url(db_arg) + else: + # A bare path. Three slashes then the path: an absolute path therefore + # yields the four-slash form SQLAlchemy expects. + url = f"sqlite:///{db_arg}" + + if not url.startswith(_SUPPORTED_DIALECTS): + dialect = url.split("://", 1)[0] + raise ValueError( + f"unsupported database dialect {dialect!r}; " + f"these tools support {' and '.join(_SUPPORTED_DIALECTS)}" + ) + return url + + +def as_date(value: object) -> date: + """Return a ``date`` for whatever the driver gave back for a DATE column. + + SQLite has no date type: both a ``DATE`` column and ``date(timestamp)`` come + back as ``str``. psycopg2 adapts PostgreSQL ``DATE`` to ``datetime.date``, + and ``date.fromisoformat`` raises ``TypeError`` on one of those. The tools + run on both dialects, so they must not assume either representation. + + Args: + value: A ``date``, a ``datetime``, or an ISO-8601 date string. + + Returns: + The corresponding ``date``. + + Raises: + ValueError: If ``value`` is None, or is a string that is not a date. + Null is refused rather than tolerated: a null grouping key would + collapse every null row into one fabricated day. + """ + if value is None: + raise ValueError("expected a date, got null") + # datetime is a subclass of date, so narrow it before the isinstance below. + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return date.fromisoformat(str(value)) diff --git a/backend/tools/backfill_livelink_odometer.py b/backend/tools/backfill_livelink_odometer.py index b5a32e09..57c80e4f 100644 --- a/backend/tools/backfill_livelink_odometer.py +++ b/backend/tools/backfill_livelink_odometer.py @@ -41,6 +41,7 @@ odometer_value_to_km, resolve_odometer_unit, ) +from tools._tool_db import as_date, resolve_sync_url # noqa: E402 #: Below this, a "mi" device's stored telemetry already sits in the same range #: as the vehicle's kilometre odometer records, so it has been converted and @@ -51,7 +52,12 @@ def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--db", required=True, help="Path to mygarage.db") + parser.add_argument( + "--db", + help=( + "Database to operate on: a path to mygarage.db, or a full SQLAlchemy URL (postgresql+asyncpg://...). Omit to use the instance's configured database, which is the right choice when running inside the container." + ), + ) parser.add_argument("--vin", help="Limit to one VIN (default: every affected vehicle)") parser.add_argument( "--apply", @@ -64,7 +70,7 @@ def _parse_args() -> argparse.Namespace: def main() -> int: """Reconstruct and optionally write the missing odometer records.""" args = _parse_args() - engine = create_engine(f"sqlite:///{args.db}") + engine = create_engine(resolve_sync_url(args.db)) with engine.begin() as conn: devices = { @@ -135,7 +141,7 @@ def main() -> int: continue if args.vin and row.vin != args.vin: continue - day = date.fromisoformat(row.day) + day = as_date(row.day) key = (row.vin, day) if key not in best or row.value > best[key][0]: best[key] = (row.value, row.param_key, row.device_id) @@ -146,7 +152,7 @@ def main() -> int: # Days that already carry a record of any source are left alone. taken: set[tuple[str, date]] = { - (row.vin, date.fromisoformat(row.date)) + (row.vin, as_date(row.date)) for row in conn.execute(text("SELECT vin, date FROM odometer_records")) } @@ -160,7 +166,7 @@ def main() -> int: for row in conn.execute( text("SELECT vin, date, odometer_km FROM odometer_records ORDER BY date") ): - existing[row.vin].append((date.fromisoformat(row.date), float(row.odometer_km))) + existing[row.vin].append((as_date(row.date), float(row.odometer_km))) def _floor_for(vin: str, day: date) -> float: """Highest odometer already recorded on or before ``day``.""" diff --git a/backend/tools/fix_session_odometer_units.py b/backend/tools/fix_session_odometer_units.py index 3eaad8b4..3eb0bd15 100644 --- a/backend/tools/fix_session_odometer_units.py +++ b/backend/tools/fix_session_odometer_units.py @@ -41,6 +41,7 @@ sys.path.insert(0, ".") from app.utils.units import UnitConverter # noqa: E402 +from tools._tool_db import resolve_sync_url # noqa: E402 #: Above this, the vehicle's km odometer records tower over its session #: odometers by roughly the miles factor (1.609), so the sessions are still in @@ -57,7 +58,12 @@ def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--db", required=True, help="Path to mygarage.db") + parser.add_argument( + "--db", + help=( + "Database to operate on: a path to mygarage.db, or a full SQLAlchemy URL (postgresql+asyncpg://...). Omit to use the instance's configured database, which is the right choice when running inside the container." + ), + ) parser.add_argument( "--apply", action="store_true", @@ -69,7 +75,7 @@ def _parse_args() -> argparse.Namespace: def main() -> int: """Convert miles-recorded session odometers to kilometres.""" args = _parse_args() - engine = create_engine(f"sqlite:///{args.db}") + engine = create_engine(resolve_sync_url(args.db)) with engine.begin() as conn: devices = [ diff --git a/backend/tools/normalize_telemetry_odometer_units.py b/backend/tools/normalize_telemetry_odometer_units.py index 059194c1..b894c68b 100644 --- a/backend/tools/normalize_telemetry_odometer_units.py +++ b/backend/tools/normalize_telemetry_odometer_units.py @@ -46,6 +46,7 @@ resolve_odometer_unit, ) from app.utils.units import UnitConverter # noqa: E402 +from tools._tool_db import resolve_sync_url # noqa: E402 #: Above this, the vehicle's km odometer records tower over its stored odometer #: telemetry by roughly the miles factor, so the telemetry is still in miles. @@ -91,7 +92,12 @@ def _mixed_units_step(conn, device_id: str, param_key: str): def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--db", required=True, help="Path to mygarage.db") + parser.add_argument( + "--db", + help=( + "Database to operate on: a path to mygarage.db, or a full SQLAlchemy URL (postgresql+asyncpg://...). Omit to use the instance's configured database, which is the right choice when running inside the container." + ), + ) parser.add_argument( "--apply", action="store_true", @@ -103,7 +109,7 @@ def _parse_args() -> argparse.Namespace: def main() -> int: """Convert miles-recorded odometer telemetry to kilometres.""" args = _parse_args() - engine = create_engine(f"sqlite:///{args.db}") + engine = create_engine(resolve_sync_url(args.db)) factor = float(UnitConverter.MILES_TO_KM) with engine.begin() as conn: @@ -197,7 +203,11 @@ def main() -> int: f"\nDRY RUN - {total_hist:,} historical and {total_latest} latest row(s) " "would be converted. Re-run with --apply." ) - return 0 + # A refused key must reach the caller's exit status, not just the + # console: the upgrade note has operators dry-run first, and a + # script gating --apply on this would otherwise apply what the dry + # run just refused. Matches fix_session_odometer_units.py. + return 2 if mixed_keys else 0 for device_id, vin, param_key in plan: conn.execute( diff --git a/backend/tools/recompute_session_aggregates.py b/backend/tools/recompute_session_aggregates.py index 61071a8b..ece07629 100644 --- a/backend/tools/recompute_session_aggregates.py +++ b/backend/tools/recompute_session_aggregates.py @@ -145,6 +145,32 @@ async def run(args: argparse.Namespace) -> int: for sid, before_v, after_v in speed_gains[:10]: print(f" session {sid}: max speed {before_v:,.0f} -> {after_v:,.0f} km/h") + # Both directions, because only reporting the losses describes this as + # damage. A session gains distance when a finer source than the odometer + # is available in its window (see app/utils/distance_counters.py), and on + # the hardware that motivated it that is most of them. + measured = [ + (sid, after["distance_km"]) + for sid, before, after in changed + if before["distance_km"] is None and after["distance_km"] is not None + ] + if measured: + total = sum(km for _, km in measured if km) + print( + f"\n{len(measured):,} session(s) get a distance where they had none " + f"({total:,.0f} km in total)." + ) + + distance_gains = _movers(changed, "distance_km", rising=True) + if distance_gains: + total = sum(after_v - before_v for _, before_v, after_v in distance_gains) + print( + f"\n{len(distance_gains):,} session(s) gain distance from a finer source " + f"than the odometer ({total:,.0f} km in total). Largest:" + ) + for sid, before_v, after_v in distance_gains[:10]: + print(f" session {sid}: distance {before_v:,.1f} -> {after_v:,.1f} km") + distance_drops = _movers(changed, "distance_km", rising=False) if distance_drops: total = sum(before_v - after_v for _, before_v, after_v in distance_drops) diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index 683957e7..a7611526 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -263,18 +263,30 @@ test.describe('Settings: units (authenticated)', () => { request, }) => { // Seeded canonically (kPa, mm) so the browser is the only thing that - // converts. `POST /tires` upserts by position, so a re-run resets it. - const seeded = await request.post(`${API_BASE}/vehicles/${TEST_VEHICLE.vin}/tires`, { - headers: admin.headers, - data: { - vin: TEST_VEHICLE.vin, - position: 'FL', - tread_depth_mm: 8, - pressure_kpa: 200, - min_tread_mm: 2, - }, - }) - expect(seeded.status(), `Seed tire failed: ${await seeded.text()}`).toBe(201) + // converts. + // + // v3.3.0: `POST /tires` no longer takes a position and no longer upserts + // by one -- a tire is a thing you own, and mounting is a separate + // operation. Seeding goes through create-and-mount, which is atomic. A + // re-run no longer resets the corner, so the seed tolerates the 409 it + // gets when FL is already occupied from a previous run. + const seeded = await request.post( + `${API_BASE}/vehicles/${TEST_VEHICLE.vin}/tires/create-and-mount`, + { + headers: admin.headers, + data: { + vin: TEST_VEHICLE.vin, + position: 'FL', + tread_depth_mm: 8, + pressure_kpa: 200, + min_tread_mm: 2, + }, + } + ) + expect( + [201, 409], + `Seed tire failed: ${await seeded.text()}` + ).toContain(seeded.status()) // 1. Set Custom, with pressure on PSI. await page.goto('/settings') diff --git a/frontend/e2e/tires.spec.ts b/frontend/e2e/tires.spec.ts new file mode 100644 index 00000000..30f47e41 --- /dev/null +++ b/frontend/e2e/tires.spec.ts @@ -0,0 +1,647 @@ +import { request as apiRequest } from '@playwright/test' + +import { test, expect } from './helpers/fixtures' +import { adminSessionFromStorageState, type AdminSession } from './helpers/seed' + +/** + * The tire lifecycle, driven through the browser. + * + * These flows had no end-to-end coverage at all before v3.3.0, which is how + * `POST /tires` could stop accepting a `position` and only one incidental + * settings test noticed. + * + * What is worth proving here rather than in a unit test: the seven new + * response fields survive serialisation, the drawers submit shapes the API + * accepts, and a stored tire renders as a tire rather than as a corner whose + * label failed to resolve. All three are cross-layer and all three were + * invisible to the component tests, which mock the hooks. + */ + +const ROOT_BASE_URL = 'http://localhost:3000' +const API_BASE = `${ROOT_BASE_URL}/api` +const AUTH_FILE = './e2e/.auth/user.json' + +/** The admin session, resolved once for this file (see settings.spec.ts). */ +let cachedAdmin: AdminSession | null = null + +async function adminSession(): Promise { + if (cachedAdmin !== null) return cachedAdmin + const context = await apiRequest.newContext({ baseURL: ROOT_BASE_URL }) + try { + cachedAdmin = await adminSessionFromStorageState(context, API_BASE, AUTH_FILE) + return cachedAdmin + } finally { + await context.dispose() + } +} + +/** + * A vehicle created fresh for this file, on every run. + * + * NOT the shared TEST_VEHICLE. Two reasons, both learned the hard way: + * + * - Corners are claimable once now, so tests that mount tires are no longer + * idempotent. `reuseExistingServer` keeps the e2e backend and its database + * alive between local invocations, so a second run found every position + * taken and either skipped (proving nothing) or failed in a way that looked + * like a product bug. + * - The first attempt at a per-test vehicle reused TEST_VEHICLE's make, model + * and year, which put a second identical card on the dashboard and broke two + * assertions in vehicle.spec.ts with a strict-mode violation. Distinctive + * make/model, so no other spec's text matcher can see it. + */ +const vehicleVins = new Map() + +async function tireVehicle( + request: import('@playwright/test').APIRequestContext, + label = 'default' +): Promise { + // Keyed by label so a test needing a corner another test already claimed can + // ask for its own rig. A single shared rig is not enough: there are five + // positions, the rotation test claims four of them and the unbounded test + // holds SPARE, so anything else wanting a corner gets a 409 that looks like + // a product bug. + const cached = vehicleVins.get(label) + if (cached !== undefined) return cached + const admin = await adminSession() + // 17 chars, no I/O/Q -- VIN validation rejects those. + const vin = ('TRET' + Math.random().toString(36).slice(2).toUpperCase()) + .replace(/[IOQ]/g, 'X') + .padEnd(17, '0') + .slice(0, 17) + const made = await request.post(`${API_BASE}/vehicles`, { + headers: admin.headers, + data: { + vin, + nickname: 'E2E Tire Rig', + vehicle_type: 'Car', + year: 1999, + make: 'TireRigMake', + model: 'TireRigModel', + }, + }) + expect( + [201, 409].includes(made.status()), + `seed tire vehicle: ${made.status()} ${await made.text()}` + ).toBeTruthy() + vehicleVins.set(label, vin) + return vin +} + +async function openTires(page: import('@playwright/test').Page, vin: string) { + await page.goto(`/vehicles/${vin}`) + await expect(page.getByRole('heading', { name: 'E2E Tire Rig' })).toBeVisible({ + timeout: 15000, + }) + await page.getByRole('tab', { name: 'Maintenance' }).click() + await page.getByRole('tab', { name: 'Tires' }).click() + // `exact` matters: without it this also matches the EmptyState's "No tires + // tracked yet" heading, and the two together are a strict-mode violation. + // Latent until now because every other test in this file seeded a tire + // through the API first, so the empty state -- the state a new user is + // actually in -- had never been rendered here. + await expect(page.getByRole('heading', { name: 'Tires', exact: true })).toBeVisible({ + timeout: 10000, + }) +} + +/** + * Delete every rig this file created. + * + * Not optional tidiness: the rigs appear on the dashboard, and + * `vehicle.spec.ts` asserts against a single "View Details" button and a + * unique vehicle heading. Leaving three extra vehicles behind turned both of + * those into strict-mode violations -- a spec this file does not touch, + * failing because of data this file created. The backend suite hit the + * identical problem with a paginated listing. + */ +test.afterAll(async ({ playwright }) => { + if (vehicleVins.size === 0) return + const admin = await adminSession() + const context = await playwright.request.newContext({ baseURL: ROOT_BASE_URL }) + try { + for (const vin of vehicleVins.values()) { + await context.delete(`${API_BASE}/vehicles/${vin}`, { headers: admin.headers }) + } + } finally { + await context.dispose() + vehicleVins.clear() + } +}) + +test.describe('Tires', () => { + test('create-and-mount, then dismount, keeps the tire and frees the corner', async ({ + page, + request, + }) => { + const vin = await tireVehicle(request) + const admin = await adminSession() + // Seeded through the API so the test is about the BROWSER flow that + // follows, not about form-filling. + const created = await request.post( + `${API_BASE}/vehicles/${vin}/tires/create-and-mount`, + { + headers: admin.headers, + data: { + vin, + position: 'RR', + brand: 'E2E Michelin', + tread_depth_mm: 8, + min_tread_mm: 2, + mounted_odometer_km: 1000, + }, + } + ) + expect([201, 409], `seed failed: ${await created.text()}`).toContain(created.status()) + test.skip(created.status() === 409, 'RR already occupied by a previous run') + + await openTires(page, vin) + await expect(page.getByText('E2E Michelin')).toBeVisible({ timeout: 10000 }) + + // Dismount through the drawer, supplying the closing odometer. + await page + .getByRole('button', { name: 'Dismount' }) + .first() + .click() + const odometer = page.locator('#dismount-odometer') + await expect(odometer).toBeVisible({ timeout: 5000 }) + await odometer.fill('9000') + await page + .getByRole('button', { name: 'Dismount', exact: true }) + .last() + .click() + + // The tire is still there, now under "In storage" -- not deleted, and not + // rendered as a blank corner. + // + // Scoped to THIS tire's card. `getByText('In storage').first()` happened + // to work only because this test runs before any other tire is + // dismounted; a reordering would have made it assert against someone + // else's card. Order-dependent assertions are the same defect as the + // `.first()` one below, just not yet triggered. + const card = page.locator('.rounded-card', { hasText: 'E2E Michelin' }).first() + await expect(card).toBeVisible({ timeout: 10000 }) + await expect(card.getByText('In storage')).toBeVisible() + // And it can be put back on, which is the seasonal-swap case the whole + // mount-period model exists for. + await expect(card.getByRole('button', { name: 'Mount' })).toBeVisible() + }) + + test('a tire with no odometer bounds prompts rather than showing a zero', async ({ + page, + request, + }) => { + const vin = await tireVehicle(request) + const admin = await adminSession() + // The upgrade-day shape: mounted, but with no odometer on the mount, so + // there is no bounded distance. Every tire on every instance looks like + // this the moment migration 097 runs, which is why "0 km" here would be + // the single most visible wrong number in the release. + const created = await request.post( + `${API_BASE}/vehicles/${vin}/tires/create-and-mount`, + { + headers: admin.headers, + data: { vin, position: 'SPARE', brand: 'E2E Unbounded', tread_depth_mm: 7 }, + } + ) + expect([201, 409], `seed failed: ${await created.text()}`).toContain(created.status()) + test.skip(created.status() === 409, 'SPARE already occupied by a previous run') + + const body = await created.json() + // The contract, before the browser is involved: a spare that has never + // rolled reports its own status and no number. + expect(body.distance_status).toBe('spare_only') + expect(body.distance_km).toBeNull() + + await openTires(page, vin) + await expect(page.getByText('E2E Unbounded')).toBeVisible({ timeout: 10000 }) + + // BOTH directions, scoped to THIS tire's card. + // + // Two earlier versions of this assertion were satisfied by the wrong + // thing. Checking only that no "0 km" appears passed while the mounted + // card carried no distance row at all -- "no zero" is also true of + // "nothing rendered". Adding `getByText('Distance on tire').first()` did + // not fix it either: the previous test in this file leaves a DISMOUNTED + // tire on the page, whose storage card has that same label, so `.first()` + // matched a card this test is not about. Verified by mutation: deleting + // the mounted card's distance row must fail this test. + const card = page.locator('.rounded-card', { hasText: 'E2E Unbounded' }).first() + await expect(card).toBeVisible({ timeout: 10000 }) + await expect(card.getByText('Distance on tire')).toBeVisible() + // 'never rolled' only: the card ALSO renders 'Spare' as the position + // heading, so a looser regex matches two elements and fails strict mode. + await expect(card.getByText(/never rolled/i)).toBeVisible() + await expect(card.getByText(/^0 (km|mi)$/)).toHaveCount(0) + }) + + test('a stale client POSTing a position is rejected loudly', async ({ request }) => { + const vin = await tireVehicle(request) + const admin = await adminSession() + // The release's breaking change, asserted as a contract rather than + // described in a changelog. A browser tab left open across the upgrade + // sends exactly this payload; the 422 naming the field is what stops it + // silently creating a second, unmounted tire. + const response = await request.post(`${API_BASE}/vehicles/${vin}/tires`, { + headers: admin.headers, + data: { vin, position: 'FL', tread_depth_mm: 8 }, + }) + expect(response.status()).toBe(422) + const detail = await response.text() + expect(detail).toContain('position') + }) +}) + +test.describe('Tire rotation and retirement', () => { + test('an X-pattern rotation moves every tire', async ({ request }) => { + const vin = await tireVehicle(request, 'rotation') + // The two-phase write, end to end. `uq_tires_vin_position` is an IMMEDIATE + // unique index, so a naive one-at-a-time assignment collides on the first + // move -- every destination is occupied. This is the API-level proof that + // the vacate/flush/assign split survives a real request; the unit test + // proves the mechanism, this proves the wiring. + const admin = await adminSession() + const corners = ['FL', 'FR', 'RL', 'RR'] as const + const ids: Record = {} + + for (const position of corners) { + const created = await request.post( + `${API_BASE}/vehicles/${vin}/tires/create-and-mount`, + { + headers: admin.headers, + data: { + vin, + position, + brand: `E2E Rot ${position}`, + tread_depth_mm: 8, + mounted_odometer_km: 1000, + }, + } + ) + if (created.status() === 409) test.skip(true, `${position} already occupied`) + expect(created.status(), `seed ${position}: ${await created.text()}`).toBe(201) + ids[position] = (await created.json()).id + } + + const rotated = await request.post(`${API_BASE}/vehicles/${vin}/tires/rotate`, { + headers: admin.headers, + data: { + odometer_km: 20000, + moves: [ + { tire_id: ids.FL, position: 'RR' }, + { tire_id: ids.FR, position: 'RL' }, + { tire_id: ids.RL, position: 'FR' }, + { tire_id: ids.RR, position: 'FL' }, + ], + }, + }) + expect(rotated.status(), await rotated.text()).toBe(200) + + const placed = Object.fromEntries( + (await rotated.json()).tires.map((t: { id: number; position: string }) => [ + t.id, + t.position, + ]) + ) + expect(placed[ids.FL]).toBe('RR') + expect(placed[ids.RR]).toBe('FL') + + // Each corner's period closed at the rotation odometer, so distance stays + // attributable per position rather than pooled across the vehicle. + const listed = await request.get(`${API_BASE}/vehicles/${vin}/tires`, { + headers: admin.headers, + }) + const moved = (await listed.json()).tires.find( + (t: { id: number }) => t.id === ids.FL + ) + expect(moved.mount_periods).toHaveLength(2) + + // `complete`, and getting here took a code change. This assertion read + // `incomplete` until the rotation started publishing its odometer as a + // reading of the vehicle: the closed FL period was bounded (1000 -> + // 20000), but the new RR period is OPEN, an open period's upper bound is + // the vehicle's latest OdometerRecord, and a rotation created none. So a + // user who rotated and dutifully typed the odometer still had the all-time + // total withheld until they went and logged the same number a second time. + // + // The RR leg is 0 km because the vehicle has not been driven since, which + // is the honest reading of the recorded facts rather than a gap. + expect(moved.distance_status).toBe('complete') + expect(Number(moved.distance_km)).toBe(19000) + expect(moved.blocking_period_ids).toHaveLength(0) + }) + + test('retiring keeps the history that deleting would destroy', async ({ request }) => { + const vin = await tireVehicle(request, 'retire') + // The distinction the release turns on. Retire and DELETE are different + // endpoints because replacing a worn tire must not erase the readings and + // mount periods this feature exists to collect. + const admin = await adminSession() + const created = await request.post( + `${API_BASE}/vehicles/${vin}/tires/create-and-mount`, + { + headers: admin.headers, + data: { + vin, + position: 'SPARE', + brand: 'E2E Retire', + tread_depth_mm: 8, + mounted_odometer_km: 1000, + }, + } + ) + expect(created.status(), await created.text()).toBe(201) + const id = (await created.json()).id + + await request.post(`${API_BASE}/vehicles/${vin}/tires/${id}/readings`, { + headers: admin.headers, + data: { recorded_at: '2026-03-01', tread_depth_mm: 6, odometer_km: 15000 }, + }) + + const retired = await request.post( + `${API_BASE}/vehicles/${vin}/tires/${id}/retire`, + { headers: admin.headers, data: { dismounted_odometer_km: 20000 } } + ) + expect(retired.status(), await retired.text()).toBe(200) + const body = await retired.json() + expect(body.retired_on).not.toBeNull() + expect(body.position).toBeNull() + // Everything survives. + expect(body.readings.length).toBeGreaterThanOrEqual(1) + expect(body.mount_periods).toHaveLength(1) + expect(body.mount_periods[0].dismounted_on).not.toBeNull() + + // Out of the default listing, present when asked for. A retired tire is + // history, not inventory -- but it is not gone. + const listed = await request.get(`${API_BASE}/vehicles/${vin}/tires`, { + headers: admin.headers, + }) + const defaultIds = (await listed.json()).tires.map((t: { id: number }) => t.id) + expect(defaultIds).not.toContain(id) + + const withRetired = await request.get( + `${API_BASE}/vehicles/${vin}/tires?include_retired=true`, + { headers: admin.headers } + ) + const allIds = (await withRetired.json()).tires.map((t: { id: number }) => t.id) + expect(allIds).toContain(id) + + // And the corner it vacated is free for the replacement. + const replacement = await request.post( + `${API_BASE}/vehicles/${vin}/tires/create-and-mount`, + { + headers: admin.headers, + data: { vin, position: 'SPARE', brand: 'E2E Replacement', tread_depth_mm: 9 }, + } + ) + expect(replacement.status(), await replacement.text()).toBe(201) + }) +}) + +/** + * The same three operations, through the controls a user actually has. + * + * The block above drives rotate and retire with `request.post`, which is why + * it stayed green while `useRotateTires`, `useRetireTire` and `useCreateTire` + * had zero callers anywhere in `src/`: it proved the endpoints worked, not + * that anyone could reach them. Every assertion here goes through the browser + * for that reason. + */ +test.describe('Tire rotation and retirement, through the UI', () => { + test('the header rotate control moves every tire', async ({ page, request }) => { + const vin = await tireVehicle(request, 'rotate-ui') + const admin = await adminSession() + for (const position of ['FL', 'FR', 'RL', 'RR'] as const) { + const created = await request.post(`${API_BASE}/vehicles/${vin}/tires/create-and-mount`, { + headers: admin.headers, + data: { + vin, + position, + brand: `E2E RotUI ${position}`, + tread_depth_mm: 8, + mounted_odometer_km: 1000, + }, + }) + expect(created.status(), `seed ${position}: ${await created.text()}`).toBe(201) + } + + await openTires(page, vin) + const cardFor = (brand: string) => + page.locator('.rounded-card', { hasText: brand }).first() + await expect(cardFor('E2E RotUI FL')).toContainText('Front Left') + + await page.getByRole('button', { name: 'Rotate' }).click() + const drawer = page.getByRole('dialog') + await expect(drawer).toBeVisible({ timeout: 5000 }) + // Forward cross: the fronts go straight back, the rears cross forward. + await drawer.getByRole('button', { name: 'Forward cross' }).click() + await drawer.locator('#rotate-odometer').fill('20000') + await drawer.getByRole('button', { name: 'Rotate', exact: true }).click() + await expect(drawer).toBeHidden({ timeout: 10000 }) + + // Every corner reassigned, asserted per tire rather than as a count: a + // rotation that moved three of four and left one behind would still show + // four cards on four corners. + await expect(cardFor('E2E RotUI FL')).toContainText('Rear Left', { timeout: 10000 }) + await expect(cardFor('E2E RotUI FR')).toContainText('Rear Right') + await expect(cardFor('E2E RotUI RL')).toContainText('Front Right') + await expect(cardFor('E2E RotUI RR')).toContainText('Front Left') + }) + + test('rotate is refused, visibly, when a corner is empty', async ({ page, request }) => { + const vin = await tireVehicle(request, 'rotate-ui-partial') + const admin = await adminSession() + const created = await request.post(`${API_BASE}/vehicles/${vin}/tires/create-and-mount`, { + headers: admin.headers, + data: { vin, position: 'FL', brand: 'E2E RotPartial', tread_depth_mm: 8 }, + }) + expect(created.status(), await created.text()).toBe(201) + + await openTires(page, vin) + await expect(page.getByText('E2E RotPartial')).toBeVisible({ timeout: 10000 }) + // Disabled rather than allowed-and-rejected: a partial pattern comes back + // as a 404 or a 409 naming a corner, neither of which tells the user that + // what they actually need is a fourth tire. + await expect(page.getByRole('button', { name: 'Rotate' })).toBeDisabled() + }) + + test('retiring is reachable from the card, and keeps the tire out of the list', async ({ + page, + request, + }) => { + const vin = await tireVehicle(request, 'retire-ui') + const admin = await adminSession() + const created = await request.post(`${API_BASE}/vehicles/${vin}/tires/create-and-mount`, { + headers: admin.headers, + data: { + vin, + position: 'RL', + brand: 'E2E RetireUI', + tread_depth_mm: 8, + mounted_odometer_km: 1000, + }, + }) + expect(created.status(), await created.text()).toBe(201) + + await openTires(page, vin) + const card = page.locator('.rounded-card', { hasText: 'E2E RetireUI' }).first() + await expect(card).toBeVisible({ timeout: 10000 }) + + await card.getByRole('button', { name: 'Retire' }).click() + const drawer = page.getByRole('dialog') + await expect(drawer).toBeVisible({ timeout: 5000 }) + await drawer.locator('#retire-odometer').fill('30000') + await drawer.getByRole('button', { name: 'Retire', exact: true }).click() + + // Gone from the list, and its corner is free again for the replacement. + await expect(page.getByText('E2E RetireUI')).toBeHidden({ timeout: 10000 }) + const listed = await request.get( + `${API_BASE}/vehicles/${vin}/tires?include_retired=true`, + { headers: admin.headers } + ) + const retired = (await listed.json()).tires.filter( + (t: { brand: string; retired_on: string | null }) => t.brand === 'E2E RetireUI' + ) + // The point of retire over delete: the tire and its history are still + // there, they are just no longer inventory. + expect(retired).toHaveLength(1) + expect(retired[0].retired_on).not.toBeNull() + expect(retired[0].mount_periods.length).toBeGreaterThanOrEqual(1) + }) + + test('a tire can be entered straight into storage', async ({ page, request }) => { + const vin = await tireVehicle(request, 'storage-ui') + await openTires(page, vin) + + await page.getByRole('button', { name: 'Add Tire' }).click() + const drawer = page.getByRole('dialog') + await expect(drawer).toBeVisible({ timeout: 5000 }) + await drawer.getByRole('button', { name: 'In storage' }).click() + await drawer.getByLabel('Brand').fill('E2E StoredSet') + await drawer.getByRole('button', { name: 'Save' }).click() + await expect(drawer).toBeHidden({ timeout: 10000 }) + + // Under the storage heading, with a Mount button: a tire you own and have + // not fitted. Before this there was no way to enter one without mounting + // it onto a corner first and dismounting it again. + const card = page.locator('.rounded-card', { hasText: 'E2E StoredSet' }).first() + await expect(card).toBeVisible({ timeout: 10000 }) + await expect(card.getByRole('button', { name: 'Mount' })).toBeVisible() + }) +}) + +/** + * Tire sets, end to end. + * + * The set table and `tires.set_id` shipped with migration 097 and then sat + * unused for the same reason rotate and retire did: no schema, no route, no + * control. This drives the whole loop through the browser -- name a set, file + * tires into it, fit it -- because that is the only kind of test that would + * have noticed. + */ +test.describe('Tire sets', () => { + test('a seasonal set is named, filled and fitted', async ({ page, request }) => { + const vin = await tireVehicle(request, 'sets-ui') + const admin = await adminSession() + + // Two tires with a history at RL and RR, then taken off. The corners they + // remember are what the fit reads back, and seeding that through the API + // keeps this test about the SET flow rather than about mount forms. + for (const position of ['RL', 'RR'] as const) { + const created = await request.post(`${API_BASE}/vehicles/${vin}/tires/create-and-mount`, { + headers: admin.headers, + data: { + vin, + position, + brand: `E2E Set ${position}`, + tread_depth_mm: 8, + mounted_odometer_km: 1000, + }, + }) + expect(created.status(), `seed ${position}: ${await created.text()}`).toBe(201) + const off = await request.post( + `${API_BASE}/vehicles/${vin}/tires/${(await created.json()).id}/dismount`, + { headers: admin.headers, data: { dismounted_odometer_km: 20000 } } + ) + expect(off.status(), await off.text()).toBe(200) + } + + await openTires(page, vin) + await expect(page.getByText('E2E Set RL')).toBeVisible({ timeout: 10000 }) + + // Name the set. + await page.getByRole('button', { name: 'Sets' }).click() + let drawer = page.getByRole('dialog') + await expect(drawer).toBeVisible({ timeout: 5000 }) + await drawer.locator('#new-set-name').fill('E2E Winter') + await drawer.getByRole('button', { name: 'Add set' }).click() + await expect(drawer.getByText('E2E Winter')).toBeVisible({ timeout: 10000 }) + await drawer.getByRole('button', { name: 'Close' }).last().click() + await expect(drawer).toBeHidden({ timeout: 10000 }) + + // File both tires into it, from each tire's own edit drawer. + for (const brand of ['E2E Set RL', 'E2E Set RR']) { + const card = page.locator('.rounded-card', { hasText: brand }).first() + await card.getByRole('button', { name: 'Edit' }).click() + drawer = page.getByRole('dialog') + await expect(drawer).toBeVisible({ timeout: 5000 }) + await drawer.getByRole('button', { name: 'E2E Winter' }).click() + await drawer.getByRole('button', { name: 'Save' }).click() + await expect(drawer).toBeHidden({ timeout: 10000 }) + } + + // Fit it: one action, and neither corner is typed anywhere. + await page.getByRole('button', { name: 'Sets' }).click() + drawer = page.getByRole('dialog') + await expect(drawer.getByText('Tires: 2 · Fitted: 0')).toBeVisible({ timeout: 10000 }) + await drawer.getByRole('button', { name: 'Fit', exact: true }).click() + await drawer.locator('input[id^="set-fit-odometer-"]').fill('30000') + await drawer.getByRole('button', { name: 'Fit', exact: true }).last().click() + await expect(drawer).toBeHidden({ timeout: 10000 }) + + // Each tire back on the corner it remembered. + await expect( + page.locator('.rounded-card', { hasText: 'E2E Set RL' }).first() + ).toContainText('Rear Left', { timeout: 10000 }) + await expect( + page.locator('.rounded-card', { hasText: 'E2E Set RR' }).first() + ).toContainText('Rear Right') + }) + + test('a set that has never been fitted says so instead of guessing', async ({ + page, + request, + }) => { + const vin = await tireVehicle(request, 'sets-ui-fresh') + const admin = await adminSession() + // Straight into storage: no mount history, so no corner to put it back on. + const created = await request.post(`${API_BASE}/vehicles/${vin}/tires`, { + headers: admin.headers, + data: { vin, brand: 'E2E NeverFitted', tread_depth_mm: 8 }, + }) + expect(created.status(), await created.text()).toBe(201) + const set = await request.post(`${API_BASE}/vehicles/${vin}/tire-sets`, { + headers: admin.headers, + data: { name: 'E2E Fresh' }, + }) + expect(set.status(), await set.text()).toBe(201) + const assigned = await request.put( + `${API_BASE}/vehicles/${vin}/tires/${(await created.json()).id}`, + { headers: admin.headers, data: { set_id: (await set.json()).id } } + ) + expect(assigned.status(), await assigned.text()).toBe(200) + + await openTires(page, vin) + await page.getByRole('button', { name: 'Sets' }).click() + const drawer = page.getByRole('dialog') + await drawer.getByRole('button', { name: 'Fit', exact: true }).click() + await drawer.locator('input[id^="set-fit-odometer-"]').fill('30000') + await drawer.getByRole('button', { name: 'Fit', exact: true }).last().click() + + // Named, and nothing moves. Guessing a corner would put a tire somewhere + // the user did not choose; doing three of four would leave an arrangement + // nobody asked for. + await expect(page.getByText(/E2E NeverFitted/)).toBeVisible({ timeout: 10000 }) + await expect(drawer).toBeVisible() + }) +}) diff --git a/frontend/scripts/units.manifest.json b/frontend/scripts/units.manifest.json index 3b19dd89..719acc5a 100644 --- a/frontend/scripts/units.manifest.json +++ b/frontend/scripts/units.manifest.json @@ -358,8 +358,8 @@ { "path": "src/components/AnalyticsHelpModal.tsx", "disposition": "audited", - "digest": "ba6b81efebb7b1ec1a19db9af078a14c634e47125feb5e6bd916e540912aea88", - "reason": "no unit conversion of its own; the defect WAS imperial-only help copy, fixed by task 6b. The four fuel-economy sentences interpolate `u.consumption.label`, so an L/100km account reads 'Average L/100km:'. The cost-per-distance pair names the QUANTITY rather than a unit, deliberately: the denominator a cost rate is quoted over is task 7's decision and this copy must not pre-empt it, but it must also stop naming the mile to a reader who does not use one.", + "digest": "dfade4bbca9cb54bbaf1d90c2a57c4cc4b6bb97519bc32af00abce886510cf76", + "reason": "no unit conversion of its own; the defect WAS imperial-only help copy, fixed by task 6b. The four fuel-economy sentences interpolate `u.consumption.label`, so an L/100km account reads 'Average L/100km:'. The cost-per-distance pair names the QUANTITY rather than a unit, deliberately: the denominator a cost rate is quoted over is task 7's decision and this copy must not pre-empt it, but it must also stop naming the mile to a reader who does not use one. The tire help section added for spec B is prose from the analytics bundle and resolves no unit here. Its copy went in naming 'kilometres' and was corrected to 'distance' during this review: a fixed unit word in help text is wrong for every imperial reader, and neither the expression gate nor the hardcoded-strings gate can see it, because it lives in a locale JSON as a properly translated string.", "tests": [ "__tests__/unitCopy.resolvedProse.test.tsx" ] @@ -556,7 +556,7 @@ { "path": "src/components/LineItemEditor.tsx", "disposition": "audited", - "digest": "3df433e8016c8a46d32f691146b4b41f220086d64fd985b619a8d988ece2e64a", + "digest": "1901f063844fa2fad71e9f18d2270ba0c830cb64b5188a910e8b0452897af2da", "reason": "the reminder mileage it WRITES into due_mileage_km, through useUnitFormat. An earlier plan revision filed this component as display-only", "tests": [ "components/__tests__/LineItemEditor.mixedUnits.test.tsx", @@ -739,7 +739,7 @@ { "path": "src/components/ServiceVisitForm.tsx", "disposition": "audited", - "digest": "c697dffd32570383909a8dea9cdc9aa2165db2172cb2d32c46ef6729f56579b8", + "digest": "9be28a5d41381678faed57c03c3591ea7bbfe5c7601f7e8bbc2f5b962a5c0a90", "reason": "the odometer through useUnitFormat with a seeded origin. `system` survives for the SUPPLY quantities only (D8 exempts supplies). Ruling R3 gave canonicalToDisplay/displayToCanonical to task 5, which RULED them exempt: see src/utils/supplyUnits.ts, whose exemption is now owned by a D8 amendment rather than by a task.", "tests": [ "components/__tests__/ServiceVisitForm.mixedUnits.test.tsx", @@ -820,7 +820,7 @@ { "path": "src/components/SupplyUsedPicker.tsx", "disposition": "audited", - "digest": "d06cce9ca03b0bb5064fea347d93ef7e178c753170ab9b1b429a089006c2dd7b", + "digest": "e55c5250d25fe3a690fd5f739bf27074c751d2acb844e6fb2eaadddf4bd3b874", "reason": "renders supplyUnitLabel only; inherits the D8 quarts decision recorded on utils/supplyUnits.ts", "tests": [ "utils/__tests__/supplyUnits.test.ts" @@ -838,11 +838,20 @@ "digest": "1bbd0850596915dd77a929cc7b0736415677c9712ac7d8a64f8b845429b0d614", "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched NONE of them." }, + { + "path": "src/components/TireAnalyticsSection.tsx", + "disposition": "audited", + "digest": "abe9171c1e5a12c863612678b92f58797df49d8a73493d188bc9181b4a74cafe", + "reason": "audited: a READ-ONLY unit-bearing surface, and every figure it renders is resolved through the reader's adapters. Tread through u.tread (the table cell via `format`, the chart series via `toDisplay` with the Y axis labelled from `u.tread.label`, so the plotted numbers and the axis unit come from ONE adapter and cannot disagree). Distance, known distance and the wear projection through u.distance.format. There are no entry fields and therefore no origins to preserve: nothing here is submitted. It recomputes no distance or projection either -- both arrive already computed by TireService, the same values the tire card renders, so the two surfaces cannot drift. The prompts, the readiness counts and the series colours name no unit; the counts are dimensionless. Re-reviewed after the data fetch moved from react-query to the useEffect + api.get shape the rest of Analytics.tsx uses. That changed how the payload ARRIVES and nothing about how any figure is resolved: the same adapters format the same canonical fields.", + "tests": [ + "components/__tests__/TireAnalyticsSection.test.tsx" + ] + }, { "path": "src/components/TireList.tsx", "disposition": "audited", - "digest": "5f713bf197343bd9127b064cdc657092c75498647a832565b46591409e76d4fd", - "reason": "tread and pressure through useUnitFormat with seeded origins. Phase 5a made the reading drawer's tread OPTIONAL (#152): the at-least-one guard now resolves BOTH tread and pressure through canonicalFromUnitField before refusing, where it resolved tread alone and left pressure to be converted inside the payload literal. That is the same unit decision applied to a second field, not a new one, and both still submit the seeded canonical value when untouched. A pressure-only reading posts tread_depth_mm: null, which carries no unit; the card already guarded its tread cell with `!= null` because Tire.tread_depth_mm was nullable already. The reading-history drawer added after phase 5a renders tread, pressure and odometer through u.tread / u.pressure / u.distance, the same three adapters and the same resolved `u` the card above it uses, so a history row cannot disagree with the card. It reads canonical fields only through `num()` and formats every one of them; nothing raw reaches the DOM, and a mutation swapping the tread cell for String(reading.tread_depth_mm) fails TireList.test.tsx. The overlay button that opens it carries no unit.", + "digest": "ce3e721fce1b140216d3e8b0420f28dc6e0a340ad33c09f5f0e3abc9420fab97", + "reason": "tread and pressure through useUnitFormat with seeded origins. Phase 5a made the reading drawer's tread OPTIONAL (#152): the at-least-one guard now resolves BOTH tread and pressure through canonicalFromUnitField before refusing, where it resolved tread alone and left pressure to be converted inside the payload literal. That is the same unit decision applied to a second field, not a new one, and both still submit the seeded canonical value when untouched. A pressure-only reading posts tread_depth_mm: null, which carries no unit; the card already guarded its tread cell with `!= null` because Tire.tread_depth_mm was nullable already. The reading-history drawer added after phase 5a renders tread, pressure and odometer through u.tread / u.pressure / u.distance, the same three adapters and the same resolved `u` the card above it uses, so a history row cannot disagree with the card. It reads canonical fields only through `num()` and formats every one of them; nothing raw reaches the DOM, and a mutation swapping the tread cell for String(reading.tread_depth_mm) fails TireList.test.tsx. The overlay button that opens it carries no unit. The rotate and retire drawers added when the three unreachable flows got a UI introduce two more DISTANCE entry fields, and both take the same route as the mount and dismount ones: an empty origin (they open blank and are cleared on success, so there is no seeded canonical to preserve), submission through canonicalFromUnitField(..., u.distance), and a label from `odometerWithUnit` interpolating u.distance.label rather than naming a unit. All FOUR odometer inputs now carry step={u.distance.step}; only the reading drawer's did, and the other three fell back to the HTML default of 1. That default happens to equal u.distance.step for every distance unit in the vocabulary, both of which are whole-unit, so this is a guard against a future decimal distance unit rather than a behaviour change today. The rotation pattern chips, the move preview (two position labels per row) and the storage chip carry no unit and no number. Tire sets add exactly ONE unit-bearing control: the per-set fit odometer (`set-fit-odometer-{id}`), which takes the same route as the other four -- empty origin, `canonicalFromUnitField(..., u.distance)`, the shared `odometerWithUnit` label, `step={u.distance.step}`. Everything else the sets work introduced is dimensionless: `setMembership` interpolates two COUNTS (`Tires: {{total}} · Fitted: {{mounted}}`), the set name is free text, the membership chips carry names, and the stored-tire grouping only reorders cards whose unit-bearing cells were already dispositioned above.", "tests": [ "components/__tests__/TireList.metric.test.tsx", "components/__tests__/TireList.test.tsx" @@ -947,6 +956,12 @@ "digest": "8d5367f989b05c98b118cba98d3a7dfcf6b505fb276b099be496171436ee0764", "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched 3 of them; every hit was read in context and none is a unit decision or a unit-bearing quantity." }, + { + "path": "src/components/livelink/NoMovementSignalNotice.tsx", + "disposition": "no unit behaviour", + "digest": "c23ff956da11cf278a0da6c8523ca7b7799ef42bce4bbfbebb6e954c53386852", + "reason": "no unit behaviour: dispositioned under the CORRECTED scan (every unit stem, token, quantity name and canonical field suffix, case-insensitive, NO word boundary). Hits are 'length' inside Array.prototype.length and 'speed'/'odomet' inside the module DOCSTRING, which explains that a device reporting no recognisable speed or odometer records no drives. It renders no quantity at all, only device names, and makes no unit decision. It no longer performs any comparison: the epoch-millisecond cutoff the previous disposition described was removed when the backend took over deciding which devices are unreadable, so the component is now a filter on one boolean." + }, { "path": "src/components/livelink/VehicleLiveLinkWidget.tsx", "disposition": "audited", @@ -1028,7 +1043,7 @@ { "path": "src/components/modals/LiveLinkSettingsModal.tsx", "disposition": "no unit behaviour", - "digest": "550eaa399469391d8a9b8c7a7c568fd9d372fcfbe9ca0f2e1a797beeeeb0a543", + "digest": "ae6403423d4fd0a963aad8d48dc6de14a2ad729a749cd486a0de8e2dad82a5f6", "reason": "no unit behaviour: RE-REVIEWED for the LiveLink odometer-units fix. This file no longer matches NONE of the unit vocabulary — it now contains the literals 'km' and 'mi' — so the original scan-based justification is superseded. The disposition still holds on BEHAVIOUR, on the same ground as src/types/api.generated.ts: the added control selects a per-device DECLARATION of what units a WiCAN dongle reports its odometer in, and posts that enum verbatim to PUT /livelink/devices/{id}. It converts nothing, renders no unit-bearing quantity, and has no imperial/metric branch; the conversion it governs happens server-side in app/utils/odometer_units.py. Its three option labels are t() keys resolved from forms.json, which is audited separately." }, { @@ -1299,7 +1314,7 @@ { "path": "src/components/tabs/LiveLinkSessionsTab.tsx", "disposition": "audited", - "digest": "f5470b324c16f6035af620f2d39bc6689a6b4f306aa544e51fd7c9d54543fb12", + "digest": "e8a3ed1ec05faf5735ae4c7ad4dd295ab1c9ff5cf5cdb3d08a8515e14e678cdb", "reason": "L6 SETTLED for this file's three columns, so the unverified marker is retired here. The old reason was that nothing recorded which PID produced a value or whether it was normalised. That is now fixed at the source rather than annotated: livelink_devices carries an odometer_unit (migration 096) and SessionService converts on write, so distance_km, start_odometer and end_odometer are metric-canonical km and go through u.distance like every other quantity. Settled by normalising at write rather than by the per-value normalised flag design:129 proposed; the flag would let a consumer tell the cases apart, whereas this leaves only one case. Sessions written before the fix were converted in place by backend/tools/fix_session_odometer_units.py. Speed and temperature were already canonical and are unchanged. DELIBERATELY NOT EXTENDED: LiveLinkChartsTab.tsx and telemetryUnits.ts stay unverifiable because raw vehicle_telemetry is still device-native and a bare-PID odometer on the Live tab is still genuinely unlabelled; LiveLinkTripsTab.tsx renders DriveSession.distance_km verbatim and so is settled by the same backend change, but its rendering was not touched here and its row is left as it stands rather than upgraded unreviewed.", "tests": [ "components/tabs/__tests__/LiveLinkSessionsTab.test.tsx" @@ -1371,7 +1386,7 @@ { "path": "src/components/tabs/SettingsIntegrationsTab.tsx", "disposition": "no unit behaviour", - "digest": "a9500d42d7dee24ed4f7e94eac9fd0cbe11428d920f55cb424cb959731782d59", + "digest": "61ec567be023c2d4f322a540089e7574fa0dea037c1270de9750d12fd38a2db5", "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched 5 of them; every hit was read in context and none is a unit decision or a unit-bearing quantity." }, { @@ -1699,8 +1714,8 @@ { "path": "src/components/vehicle-detail/VehicleSpecsPanel.tsx", "disposition": "audited", - "digest": "d9672d1504579e2931e938aee03003ac484aece8147e3a9b22fc9efff8390287", - "reason": "oil capacity and lug-nut torque through useUnitFormat with seeded origins. Canonical storage is liters and Nm. The first version branched on `useUnitPreference().system`, which is collapsed out of VOLUME (spec D8), so a reader on litres with lb-ft was shown Nm: the same disagreement issue #152 was filed about, and it is pinned by a test using exactly that mixed set. Entry uses `readUnitField`, the locale-aware sibling of `canonicalFromUnitField`: this form's numeric controls are NumberInput, which renders type=\"text\" with inputMode=\"decimal\", so a comma-locale reader types `4,5` and canonicalFromUnitField's Number() would read NaN. The untouched-field rule is shared rather than reimplemented, via `unitFieldUnchanged`, so an untouched field posts back the canonical value it was seeded from and a round trip through gallons cannot drift the record. Both editor labels interpolate the resolved unit; the hardcoded 'gal'/'L' pair and the binary getTorqueUnit call are gone.", + "digest": "dbcd5f615d194037bd9c8fcb8f9189814c947ed2442c9d175d81ba96a13cdf11", + "reason": "lug-nut torque through useUnitFormat with seeded origins, and oil capacity through useOilCapacityFormat for the reason recorded on src/utils/oilCapacityUnit.ts: the resolved set has no quart token, and reading oil through the fuel adapter stored 3.785x over. Canonical storage is liters and Nm. The first version branched on `useUnitPreference().system`, which is collapsed out of VOLUME (spec D8), so a reader on litres with lb-ft was shown Nm: the same disagreement issue #152 was filed about, and it is pinned by a test using exactly that mixed set. Entry uses `readUnitField`, the locale-aware sibling of `canonicalFromUnitField`: this form's numeric controls are NumberInput, which renders type=\"text\" with inputMode=\"decimal\", so a comma-locale reader types `4,5` and canonicalFromUnitField's Number() would read NaN. The untouched-field rule is shared rather than reimplemented, via `unitFieldUnchanged`, so an untouched field posts back the canonical value it was seeded from and a round trip through gallons cannot drift the record. Both editor labels interpolate the resolved unit; the hardcoded 'gal'/'L' pair and the binary getTorqueUnit call are gone.", "tests": [ "components/vehicle-detail/__tests__/VehicleSpecsPanel.test.tsx" ] @@ -1843,8 +1858,8 @@ { "path": "src/hooks/queries/useTires.ts", "disposition": "no unit behaviour", - "digest": "0557cdb632561aad4f7888f03ac132ab2c55919a057a6cdcfeff503cf20477b2", - "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched 1 of them; every hit was read in context and none is a unit decision or a unit-bearing quantity." + "digest": "f63a74135d82b6cab9605f2d3a0d7286e0f708e3a7ab39fb6520cc791f0a4a71", + "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched 1 of them; every hit was read in context and none is a unit decision or a unit-bearing quantity. The five tire-set hooks added with D6 pass their payloads through untouched, like every hook already here: no conversion, no formatting, no unit named. The one behavioural change in this file is which query keys a tire write invalidates, which is a cache concern rather than a unit one." }, { "path": "src/hooks/queries/useTollRecords.ts", @@ -1963,7 +1978,7 @@ { "path": "src/hooks/useUnitFormat.ts", "disposition": "audited", - "digest": "6c12afadad647c485f5c204df4436c4f635b38d99b235bd7ccfe59efef8a7ce5", + "digest": "063f3d31381348aac96e177ce264533f766333d8d89723b7721904a5310bfb4b", "reason": "closes over the resolved set; the hook call sites should be using", "tests": [ "hooks/__tests__/useUnitFormat.test.tsx" @@ -1996,8 +2011,8 @@ { "path": "src/locales/en/analytics.json", "disposition": "audited", - "digest": "b55d075f5d60075b70e862a6546b7f1c7a51453cd6866e900005e93b15cb1c96", - "reason": "reviewed at this snapshot. Task 6 replaced the raw `units.volume === 'L'` prose selection with the same `{{unit}}` interpolation the volume COLUMN already used, so the two cannot disagree: totalLiters / totalGallons are gone and totalVolume takes getVolumeUnit(units). Task 6b then fixed the imperial-only help copy: the four fuel-economy keys interpolate {{unit}} and were renamed off \"Mpg\", and the cost-per-mile pair was renamed to costPerDistance* and reworded to name the quantity. Task 7 added the cost-per-distance card's label as `{{unit}}` interpolation (`fuelList.costPerDistance` / `vehicle.costPerDistance`): it used to be two hardcoded English strings inside `UnitFormatter.getCostPerDistanceLabel`, which bypassed i18n entirely, so every reader of every language was shown 'Cost/1k Miles' or 'Cost/100 km'. The unit half is composed from the resolved distance adapter, so the bundle names no unit of its own. Fix round 1 added `avgCostPerVolume` (and, where the list shows a total, `volumeTotal`) for the same reason and in the same shape: they replace two hardcoded English strings inside UnitFormatter that rendered in six summary cards. Both interpolate the unit or the already-composed value, so the bundle still names no unit of its own.", + "digest": "5f7cde7d1b30ee3e699e2740e9b46824d19b02688348d61ab3ffdf0a92dd75fa", + "reason": "reviewed at this snapshot. Task 6 replaced the raw `units.volume === 'L'` prose selection with the same `{{unit}}` interpolation the volume COLUMN already used, so the two cannot disagree: totalLiters / totalGallons are gone and totalVolume takes getVolumeUnit(units). Task 6b then fixed the imperial-only help copy: the four fuel-economy keys interpolate {{unit}} and were renamed off \"Mpg\", and the cost-per-mile pair was renamed to costPerDistance* and reworded to name the quantity. Task 7 added the cost-per-distance card's label as `{{unit}}` interpolation (`fuelList.costPerDistance` / `vehicle.costPerDistance`): it used to be two hardcoded English strings inside `UnitFormatter.getCostPerDistanceLabel`, which bypassed i18n entirely, so every reader of every language was shown 'Cost/1k Miles' or 'Cost/100 km'. The unit half is composed from the resolved distance adapter, so the bundle names no unit of its own. Fix round 1 added `avgCostPerVolume` (and, where the list shows a total, `volumeTotal`) for the same reason and in the same shape: they replace two hardcoded English strings inside UnitFormatter that rendered in six summary cards. Both interpolate the unit or the already-composed value, so the bundle still names no unit of its own. The tire keys added for spec B name no unit. `trendTitle` is 'Tread over time' with the unit carried by the chart axis; the four prompts and `noOdometerRecord` talk about odometers and distance as quantities rather than in any unit; `readinessSummary` interpolates a count. One exception was caught and fixed in this pass: `vehicleHelp.tires.body` said 'the kilometres driven on the other one'.", "tests": [ "__tests__/volumeTotalLabelLocales.test.ts", "__tests__/unitCopy.resolvedProse.test.tsx", @@ -2015,13 +2030,13 @@ { "path": "src/locales/en/common.json", "disposition": "no unit behaviour", - "digest": "f8a4933b6f4a01e2f98eadd7f5a094e293475f06e5a24f5060dffe9fce4b8658", + "digest": "c042af1d231bee9cfc6f8eafb6a33ca12b3accb528f89a6f5b2512141f6ac741", "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched 10 of them; every hit was read in context and none is a unit decision or a unit-bearing quantity." }, { "path": "src/locales/en/forms.json", "disposition": "audited", - "digest": "ab9e89d275e647da4337e4b92a1fb8fb51ff242799c73c9aaca9e07ad82ffe64", + "digest": "f1e833fb0a1c00572580acd26583f9ba1f18af9249a28110773d27b78ab333c5", "reason": "fuel.receiptTextPlaceholder is a plausible EXAMPLE receipt (ruling R5's exempt class): there is no canonical value behind it to convert. Task 6 fixed the reminder distance copy: reminder.milesUntilDue / reminder.milesRequired are renamed to reminder.distanceUntilDue / reminder.distanceRequired and their prose no longer names a unit, so the unit comes only from u.distance.label. Covered by components/__tests__/ReminderForm.distanceCopy.test.tsx, which resolves this bundle and asserts the RENDERED prose rather than a key. Re-acknowledged for the LiveLink odometer-units fix: five keys added under modal.livelink.odometerUnit* labelling a per-device hardware-units selector (\"Kilometers\"/\"Miles\"/\"Auto (from PID)\" plus help text). These name what a DONGLE reports, not what the reader sees — they are deliberately absolute unit words rather than u.distance.label, because the value being chosen is the device's unit and must not follow the viewer's display preference.", "tests": [ "components/__tests__/ReminderForm.distanceCopy.test.tsx", @@ -2045,7 +2060,7 @@ { "path": "src/locales/en/settings.json", "disposition": "audited", - "digest": "edd07a0bded5fd28567c1c9bb05f1f62fa52b8a770f5b7a3d8a039837508cb35", + "digest": "9bd2367f09df40c16be41f894f578991adc4f18e3508da2ecdbb2df8845424af", "reason": "units.resolvedDescription is COMPOSED from the resolved set, so it carries no unit names of its own: the labels are interpolated from UNIT_ADAPTERS, which is the same table every rendered quantity reads (plan 3b R1). Task 6b composed units.showBothDescription the same way, replacing its fixed 25 MPG example with one rendered through the reader's own consumption formatter, and moved events.card.milesBefore onto the reader's distance label. units.gallonUs / units.gallonUk state literal conversion facts, and units.gallonStandardDescription names the gallon on purpose: that IS the setting's subject. What it stopped saying is \"when the unit system is Imperial\", which is not a thing a resolved set has. Phase 4 task 4 added the Custom editor's English strings: `units.custom`, `units.customDescription`, the four preset-confirmation keys, eleven `units.quantities.*` headings and twenty-six `units.options.*` names, one per OPTION rather than one per quantity (D10: names are translated, symbols are not, so the select reads 'Kilopascals (kPa)' and never `kpa`). English lands in this task because a missing English key has no fallback and renders the raw identifier; the other six locales are a later task and fall back to these. `units.gallonUs` / `units.gallonUk` are deliberately NOT reused for `units.options.secondary_gallon.*`: the panel names an instance-wide setting and the option names a per-account quantity, and one string serving both would couple two retirements. Phase 4 task 5 added four keys for the instance-default card: `units.instanceDefault`, `units.instanceDefaultDescription`, `units.instanceDefaultSaved` and `units.instanceDefaultError`. None names a unit: the card renders the eleven per-quantity labels through `units.options.*`, which this bundle already carried and which `UnitSetEditor` reads, so the new copy describes WHO the default applies to and nothing about what it is. The six gallon-panel keys (`units.gallonStandard`, `gallonStandardDescription`, `gallonUs`, `gallonUk`, `gallonSaved`, `gallonError`) are KEPT although task 5 deleted their only reader, on the same ruling this file already records for `defList.gallons` and `propaneList.gallons`: unreferenced keys are not stale translations, and `__tests__/unitCopy.resolvedProse.test.tsx` carries a live recorded decision about two of them that deleting would silently discard. Verified by grep that no code reads any of the six.", "tests": [ "__tests__/unitDescriptionLocales.test.ts", @@ -2063,8 +2078,8 @@ { "path": "src/locales/en/vehicles.json", "disposition": "audited", - "digest": "ccb4e6c8ef025e18f0bf091b57a13152a1031e2e1683a4899c8cef1d9d95f35a", - "reason": "windowSticker.misc.fuelEconomyMpg and the warranty placeholders reproduce a US Monroney label verbatim and are exempt for the same reason as WindowStickerUpload. defList.gallons and propaneList.gallons still EXIST in `en` and in these bundles; what changed is that no code reads them any more (both list headers moved to the `volumeUnit` interpolation, and their tests assert the old key is absent from the rendered header). Unreferenced keys, not stale translations: verified by grep, because the first version of this sentence said `en` had dropped them and `en` had not. Task 6 replaced the raw `units.volume === 'L'` prose selection with the same `{{unit}}` interpolation the volume COLUMN already used, so the two cannot disagree: totalLiters / totalGallons are gone and totalVolume takes getVolumeUnit(units). Task 7 added the cost-per-distance card's label as `{{unit}}` interpolation (`fuelList.costPerDistance` / `vehicle.costPerDistance`): it used to be two hardcoded English strings inside `UnitFormatter.getCostPerDistanceLabel`, which bypassed i18n entirely, so every reader of every language was shown 'Cost/1k Miles' or 'Cost/100 km'. The unit half is composed from the resolved distance adapter, so the bundle names no unit of its own. Fix round 1 added `avgCostPerVolume` (and, where the list shows a total, `volumeTotal`) for the same reason and in the same shape: they replace two hardcoded English strings inside UnitFormatter that rendered in six summary cards. Both interpolate the unit or the already-composed value, so the bundle still names no unit of its own. Phase 5a (#152) replaced tireList.treadRequired with tireList.treadOrPressureRequired, the refusal message for a reading that measures nothing. Neither string names or implies a unit: the tread and pressure FIELDS are labelled by the existing treadWithUnit / pressureWithUnit interpolations, which are unchanged. The tireList.history* keys and tireList.odometer added with the reading-history drawer name no unit either: odometer is a bare label matching the existing bare tread and pressure labels on the card, and the value beside each is composed by the resolved adapter rather than by the bundle.", + "digest": "4bb2fb9e8c42f09d9f93305e3be8e383355d1a29f6d226b15eb5cc802a218460", + "reason": "windowSticker.misc.fuelEconomyMpg and the warranty placeholders reproduce a US Monroney label verbatim and are exempt for the same reason as WindowStickerUpload. defList.gallons and propaneList.gallons still EXIST in `en` and in these bundles; what changed is that no code reads them any more (both list headers moved to the `volumeUnit` interpolation, and their tests assert the old key is absent from the rendered header). Unreferenced keys, not stale translations: verified by grep, because the first version of this sentence said `en` had dropped them and `en` had not. Task 6 replaced the raw `units.volume === 'L'` prose selection with the same `{{unit}}` interpolation the volume COLUMN already used, so the two cannot disagree: totalLiters / totalGallons are gone and totalVolume takes getVolumeUnit(units). Task 7 added the cost-per-distance card's label as `{{unit}}` interpolation (`fuelList.costPerDistance` / `vehicle.costPerDistance`): it used to be two hardcoded English strings inside `UnitFormatter.getCostPerDistanceLabel`, which bypassed i18n entirely, so every reader of every language was shown 'Cost/1k Miles' or 'Cost/100 km'. The unit half is composed from the resolved distance adapter, so the bundle names no unit of its own. Fix round 1 added `avgCostPerVolume` (and, where the list shows a total, `volumeTotal`) for the same reason and in the same shape: they replace two hardcoded English strings inside UnitFormatter that rendered in six summary cards. Both interpolate the unit or the already-composed value, so the bundle still names no unit of its own. Phase 5a (#152) replaced tireList.treadRequired with tireList.treadOrPressureRequired, the refusal message for a reading that measures nothing. Neither string names or implies a unit: the tread and pressure FIELDS are labelled by the existing treadWithUnit / pressureWithUnit interpolations, which are unchanged. The tireList.history* keys and tireList.odometer added with the reading-history drawer name no unit either: odometer is a bare label matching the existing bare tread and pressure labels on the card, and the value beside each is composed by the resolved adapter rather than by the bundle. The seventeen tireList keys added for rotate, retire and create-into-storage name no unit. The odometer fields in both new drawers are labelled by the existing `odometerWithUnit` interpolation, and the two hints that talk about an odometer (`rotateOdometerHint`, `retireHint`) describe what the reading is FOR without spelling a unit, so neither can disagree with the resolved adapter. The twenty-three tireList keys added for sets name no unit either. `setMembership` interpolates two counts, not quantities; the fit drawer's odometer field is labelled by the existing `odometerWithUnit`; and `setFitHint` describes what the fit does to corners without spelling a distance.", "tests": [ "__tests__/volumeTotalLabelLocales.test.ts", "components/__tests__/FuelRecordList.test.tsx" @@ -2097,8 +2112,8 @@ { "path": "src/pages/Analytics.tsx", "disposition": "audited", - "digest": "592697a50254e30beecf6a3675f3992aca40eda2c7ed6b3c43dce059650943c5", - "reason": "gate-detectable unit decisions, recorded not asserted clean (plan 3b task 1 ordering). Task 6 migrated the DISTANCE sites onto the resolved `units.distance` token; task 6b migrated the consumption and fuel-rate surface, INCLUDING the two chart-data transforms that made the same decision with a raw `UnitConverter` call under a bare `system === 'metric'` (a label moved without its series would have plotted litres under an MPG axis). Task 7 migrated the cost-per-distance pair off the collapsed binary system: the value and the label now both read `units.distance`, and the label goes through `t()` instead of returning hardcoded English. All three of its sites move together: the summary card's heading, the card's figure, and the CSV export row, which carried the same hardcoded English label into a downloaded file. Task 6 replaced the raw `units.volume === 'L'` prose selection with the same `{{unit}}` interpolation the volume COLUMN already used, so the two cannot disagree: totalLiters / totalGallons are gone and totalVolume takes getVolumeUnit(units). The evidence for this file is the gate, whose count for it is now zero, plus validate-i18n-usage.ts, which fails if the new key is absent from English; the RENDERED proof of the label-and-value pairing is in FuelRecordList.test.tsx, which drives the identical helper pair through the identical mixed sets. Fix round 1 closed the other half of that i18n bug, which was one card to the LEFT: `UnitFormatter.getCostPerVolumeLabel` returned the English words 'Avg Cost/' glued to the unit symbol and `formatVolumeTotal` appended the English word 'total', neither through `t()`, so a German reader read `Kosten/100 km` beside `Avg Cost/gal` and `45,5 L gesamt`. Both statics are DELETED: their prose halves are `avgCostPerVolume` / `volumeTotal` keys in all seven bundles and their symbol halves are `getVolumeUnit` and `formatVolumeShort`. Its one site is the DEF-analysis card, which no test rendered at all (`def_analysis` is null in both existing fixtures); Analytics.costPerDistance.test.tsx now renders it in both vocabularies.", + "digest": "5baf883377021455c3ba6e21f018c9d5daf5d60ff5ecb4003484d51b5ec1f80f", + "reason": "gate-detectable unit decisions, recorded not asserted clean (plan 3b task 1 ordering). Task 6 migrated the DISTANCE sites onto the resolved `units.distance` token; task 6b migrated the consumption and fuel-rate surface, INCLUDING the two chart-data transforms that made the same decision with a raw `UnitConverter` call under a bare `system === 'metric'` (a label moved without its series would have plotted litres under an MPG axis). Task 7 migrated the cost-per-distance pair off the collapsed binary system: the value and the label now both read `units.distance`, and the label goes through `t()` instead of returning hardcoded English. All three of its sites move together: the summary card's heading, the card's figure, and the CSV export row, which carried the same hardcoded English label into a downloaded file. Task 6 replaced the raw `units.volume === 'L'` prose selection with the same `{{unit}}` interpolation the volume COLUMN already used, so the two cannot disagree: totalLiters / totalGallons are gone and totalVolume takes getVolumeUnit(units). The evidence for this file is the gate, whose count for it is now zero, plus validate-i18n-usage.ts, which fails if the new key is absent from English; the RENDERED proof of the label-and-value pairing is in FuelRecordList.test.tsx, which drives the identical helper pair through the identical mixed sets. Fix round 1 closed the other half of that i18n bug, which was one card to the LEFT: `UnitFormatter.getCostPerVolumeLabel` returned the English words 'Avg Cost/' glued to the unit symbol and `formatVolumeTotal` appended the English word 'total', neither through `t()`, so a German reader read `Kosten/100 km` beside `Avg Cost/gal` and `45,5 L gesamt`. Both statics are DELETED: their prose halves are `avgCostPerVolume` / `volumeTotal` keys in all seven bundles and their symbol halves are `getVolumeUnit` and `formatVolumeShort`. Its one site is the DEF-analysis card, which no test rendered at all (`def_analysis` is null in both existing fixtures); Analytics.costPerDistance.test.tsx now renders it in both vocabularies. The tire section added for spec B is one import and a two-line render delegating to TireAnalyticsSection, which has its own row. No unit is resolved, formatted or named in this file by that change.", "tests": [ "pages/__tests__/Analytics.anomalies.test.tsx", "pages/__tests__/Analytics.hours.test.tsx", @@ -2434,8 +2449,8 @@ { "path": "src/types/api.generated.ts", "disposition": "no unit behaviour", - "digest": "ba8facefa4433d6450b803bd366ffabe6c0ef42837169eeccb267977cc7b2b27", - "reason": "no unit behaviour: a GENERATED type module. It declares the `UnitSet` vocabulary itself (`distance: \"km\" | \"mi\"` and nine more), so it is dense with unit vocabulary in a unit context, but it declares types rather than deciding or rendering anything. Its digest is load-bearing rather than incidental: this is the file the token-branch leg derives its vocabularies from, so a new unit token arriving from the backend is a review event, and an unrelated schema change costs one cheap re-acknowledgement. Re-acknowledged for the LiveLink odometer-units fix: the regenerated schema adds LiveLinkDeviceResponse.odometer_unit (`string | null`) and LiveLinkDeviceUpdate.odometer_unit (`\"km\" | \"mi\" | \"auto\" | null`). Reviewed as NOT a new UnitSet token: 'km'/'mi' here name what a piece of hardware reports rather than what a user wants displayed, and 'auto' is a clear-the-override sentinel, not a unit. The UnitSet vocabulary is untouched." + "digest": "e80db5e35eeaa85bee0c9fa5580fd487510dee34c90232e4bcd672cde6b9960f", + "reason": "no unit behaviour: a GENERATED type module. It declares the `UnitSet` vocabulary itself (`distance: \"km\" | \"mi\"` and nine more), so it is dense with unit vocabulary in a unit context, but it declares types rather than deciding or rendering anything. Its digest is load-bearing rather than incidental: this is the file the token-branch leg derives its vocabularies from, so a new unit token arriving from the backend is a review event, and an unrelated schema change costs one cheap re-acknowledgement. Re-acknowledged for the LiveLink odometer-units fix: the regenerated schema adds LiveLinkDeviceResponse.odometer_unit (`string | null`) and LiveLinkDeviceUpdate.odometer_unit (`\"km\" | \"mi\" | \"auto\" | null`). Reviewed as NOT a new UnitSet token: 'km'/'mi' here name what a piece of hardware reports rather than what a user wants displayed, and 'auto' is a clear-the-override sentinel, not a unit. The UnitSet vocabulary is untouched. Re-reviewed after the tire-set endpoints regenerated it. The new schemas (TireSetResponse / Create / Update / MountRequest and the `set_id` on TireUpdate) add one canonical `odometer_km` field and no new unit TOKEN, so the vocabulary this row is load-bearing for is unchanged. Re-reviewed after the tire analytics endpoint regenerated it. TireAnalyticsSummary and TireReadiness add counts and a reuse of TireResponse; no new unit TOKEN enters the vocabulary this row is load-bearing for." }, { "path": "src/types/attachment.ts", @@ -2500,7 +2515,7 @@ { "path": "src/types/livelink.ts", "disposition": "no unit behaviour", - "digest": "1754c2237d9d1657d0e35fd5e5b9e94a49f8b8adc5c930599f7aec30fea25b6b", + "digest": "98b47544b20927484b148c859a5abe82e1e59de1a8707326ccedbae22cfa9c84", "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched 3 of them; every hit was read in context and none is a unit decision or a unit-bearing quantity." }, { @@ -2512,8 +2527,8 @@ { "path": "src/types/odometer.ts", "disposition": "no unit behaviour", - "digest": "6897fc2ef7a397a015f18b8e34ec4095e13a0bf915be088ff1686e137dce4e4c", - "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched 2 of them; every hit was read in context and none is a unit decision or a unit-bearing quantity." + "digest": "1c7ad58e1baa22bb4588253596e4e01bd3765d7053f2eefb5d5b933b9dd07cf9", + "reason": "no unit behaviour: re-reviewed after the file gained a corrected `OdometerSource` union (the backend writes nine source labels, not the three this listed) and the comment deriving it. The stem scan matches more often now -- 'odometer', 'fuel' and 'def' all appear -- and every hit is a TYPE NAME, a database column named in prose, or a source LABEL. The file declares no numeric value, performs no conversion and names no unit, so the disposition it was given under the CORRECTED scan (every unit stem, token, quantity name and canonical field suffix, matched case-insensitively with NO word boundary) still holds. A hit count is deliberately not restated: it rots on the next comment edit, and the criterion is what the next reviewer needs. Re-reviewed again when the set fit began publishing its own odometer reading: 'tire_set' joins the union, derived the same way as the rest. Still a string vocabulary with no numeric value, no conversion and no unit." }, { "path": "src/types/photo.ts", @@ -2572,8 +2587,8 @@ { "path": "src/types/tire.ts", "disposition": "no unit behaviour", - "digest": "0cb084c541dd4d9a086f129339e12fc79bdb0da94902b12e34895b6e2d1340ed", - "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched 2 of them; every hit was read in context and none is a unit decision or a unit-bearing quantity." + "digest": "1eec2ffe32b1f8ae1536c0b0f1c3106cd41e1d230953a08bd30f10e09d952029", + "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched 2 of them; every hit was read in context and none is a unit decision or a unit-bearing quantity. Re-reviewed after six tire-set aliases were added. Every one is a re-export of a generated schema type (`TireSetResponse` and friends); this file still declares no value, converts nothing and names no unit. The set schemas' own `odometer_km` fields are typed in `api.generated.ts`, which has its own row." }, { "path": "src/types/toll.ts", @@ -2697,6 +2712,24 @@ "digest": "b2a06e5a85b943e049c2d22bd6f56cf89d6139d3fd270a309a36457ecc1875aa", "reason": "no unit behaviour: dispositioned under the CORRECTED scan: every unit stem, token, quantity name and canonical field suffix this codebase uses, matched case-insensitively with NO word boundary. The earlier word-boundary version was a floor (it called AnalyticsHelpModal.tsx clean while it renders \"Average MPG:\" to every user) and it was superseded before any disposition in this file was written. This file matched NONE of them." }, + { + "path": "src/utils/oilCapacityUnit.ts", + "disposition": "audited", + "digest": "a9452abd16ae176c57bbef080d5dd335ba2453f9a70299a4fed89050892ca1c4", + "reason": "the one quantity the resolved vocabulary cannot name. Engine oil is read in quarts wherever fuel is read in gallons, and UnitSet.volume is L | gal_us | gal_uk with no quart token, so oil capacity rode the FUEL adapter and asked a gal_us reader for gallons of engine oil. Derives the quart as LITERS_PER_VOLUME_UNIT[gal_x] / 4 from the gallon the reader already resolved to, which is the derivation utils/supplyUnits.ts recommends in its own docstring and does not use. A Record keyed by the volume token rather than a branch on it, so a volume unit added later fails tsc here instead of falling silently into the wrong leg.", + "tests": [ + "utils/__tests__/oilCapacityUnit.test.ts", + "components/vehicle-detail/__tests__/VehicleSpecsPanel.test.tsx" + ], + "findings": [ + "FIXED, 3.785x, measured on a real instance before this landed: two vehicles stored oil capacity in litres-read-as-gallons. A 6.7 Cummins entered as 12 stored 45.42 L against a true 11.36, and a Mirage entered as 3.2 stored 12.11 against 3.03. SILENT, because the round trip is symmetric: the card read 12 gal straight back and only a canonical-value assertion can catch it, which is why VehicleSpecsPanel.test.tsx now pins payload.oil_capacity_liters and not just the rendered string.", + "UK QUART HANDLED HERE, still deferred in supplies. supplyUnits.ts hardcodes the US liquid quart (0.946352946) and records the resulting 20.1 percent defect for UK instances; this file derives the flavour instead, so a gal_uk reader gets 1.1365225 L per quart on day one. Supplies is deliberately NOT changed with it: its factor re-interprets quantities already stored and no column records which quart a row was written in, which is the D8 amendment's data decision, not this one's.", + "PRECISION CHANGED 2 -> 1 for this quantity only. Oil capacities are quoted to one decimal everywhere, so a metric reader now sees 4.7 L where they saw 4.70 L. Deliberate and separate from the unit fix; pinned by the litres-with-lb-ft discriminator test." + ], + "owners": [ + "deferred" + ] + }, { "path": "src/utils/parseAPITimestamp.ts", "disposition": "no unit behaviour", @@ -2764,7 +2797,7 @@ { "path": "src/utils/unitFormat.ts", "disposition": "audited", - "digest": "2e581da7ebe4a3d0aaaacde9c2461dc2e1ea54a49127ccc3f6d4d457169725de", + "digest": "7c1978f79fbcbcb79183d250be4afb96f729b25ab36d2e084d6c292911c9b23b", "reason": "makeUnitFormat plus the origin-preserving pair seedUnitField / canonicalFromUnitField, and resolvedUnitSummary, which lists a resolved set's ten labels for the settings description (plan 3b R1). Re-read for task 6: formatPrimary renders one unit even when show-both is on, because the binary distance API took the counterpart as an argument and eleven read sites passed false to suppress it. It also now holds formatVolumePerDistance and volumePerDistanceLabel, moved out of utils/units.ts by task 6: both halves of that compound unit come from adapterFor, where the retired pair derived the DISTANCE half from units.volume. That collapse is the category the units gate deliberately cannot detect (call-site identical to the correct formatVolume(units)), so it was carried here rather than in the baseline. Task 6b added `formatFuelRate(units, lPerHr, showBoth)` and `fuelRateLabel(units)`: a fuel rate is volume per a DIMENSIONLESS engine hour, so it composes the volume adapter with a fixed `/hr` suffix rather than being an eleventh quantity, mirroring `backend/app/utils/unit_derived.py::format_fuel_rate`. The suffix is applied to each representation independently, never to a completed show-both string, which is why it cannot be a `QuantityFormat` member. `format`'s docstring now states the one reachable HALF-ABSENT composition: a linear primary at zero paired with a reciprocal counterpart renders '0.00 L/100km (N/A)', because the null short-circuit is about an absent PRIMARY. Pinned by unitFormatFuelRate.test.ts so nobody 'fixes' it into 'N/A' and loses a true number. Task 7 exported the untouched predicate as `unitFieldUnchanged` so PRICE can share it: price is not a quantity (its display MULTIPLIES by the denominator factor where a volume adapter divides, so canonical $1.20/L through a UK-gallon volume formatter renders 0.26 where price semantics want ~5.46/gal), so its seed/read pair lives beside the price arithmetic in utils/decimalSafe.ts and calls back into this one decision rather than copying it. The three legs of that predicate are ordered: exact string, then BLANK (before the numeric leg, because Number('') is 0 and a field seeded '0.00' would otherwise read a cleared box as untouched), then the same number spelled differently. Task 7 added `formatCostPerDistance(units, costPerKm, currencyCode, locale)` and `costPerDistanceUnitLabel(units)`, moved from utils/units.ts for the reason the volume-per-distance pair moved: the distance half must come from `adapterFor`, which units.ts cannot import. The DENOMINATORS are unchanged (100 km and 1,000 mi, held in a `Record` over the distance token so a unit added later cannot compile without stating what it is quoted over); what changed is which of the two an account gets. The label returns only the UNIT half, because the retired `getCostPerDistanceLabel` returned hardcoded English ('Cost/1k Miles', 'Cost/100 km') to every reader of every language; the 'Cost/' is now a translated key at the call site, the same split `fuelList.avgFuelRate` uses. It also exported `unitFieldUnchanged` for the price pair. Mutation testing found both Analytics call sites of that pair completely unpinned (fixing either to a kilometre set killed nothing), so pages/__tests__/Analytics.costPerDistance.test.tsx now drives the card AND the CSV row through a {volume:'L', distance:'mi'} account and its mirror, asserting the label and the value together in each. Fix round 1 deleted the `?? 1` fallback on both distance-adapter calls (this file's and task 6's): `toCanonical` returns null only for an absent or NaN input and `1` is neither, so the fallback was unreachable, and a reachable one would have quoted a mile rate as a kilometre rate. Replacing it with a cast survived the whole suite, which is this phase's own test for a predicate that should not exist.", "tests": [ "utils/__tests__/unitFormat.test.ts", diff --git a/frontend/src/__tests__/settingsIntegrationsCase.test.ts b/frontend/src/__tests__/settingsIntegrationsCase.test.ts new file mode 100644 index 00000000..60b7e9f8 --- /dev/null +++ b/frontend/src/__tests__/settingsIntegrationsCase.test.ts @@ -0,0 +1,104 @@ +/** + * Casing convention for the Integrations settings tab. + * + * The rule, applied across this codebase since the MyFinances convention was + * adopted: TITLE CASE NAMES THINGS, SENTENCE CASE SAYS THINGS. A card title or + * a field label is a name. A toggle label is a statement about what the switch + * does, so it stays sentence case apart from proper nouns. + * + * The tab had both spellings on one screen -- "Recall Check Interval" beside + * "Webhook ingest token", "Enable NHTSA Integration" beside "Enable receipt + * draft parsing" -- which is what made the page read as unfinished rather than + * as a deliberate style. + * + * Asserted against the `en` bundle, not against a rendered component: the tab's + * test harness stubs `t` to return the key, so a render assertion here would + * pass on any casing at all. The other six locales are translations and set + * their own conventions; this pins the source strings only. + */ + +import { describe, it, expect } from 'vitest' +import en from '../locales/en/settings.json' + +const integrations = (en as { integrations: Record }).integrations + +/** Every word capitalised, allowing lowercase joining words after the first. */ +const MINOR_WORDS = new Set(['a', 'an', 'and', 'the', 'or', 'for', 'to', 'of', 'in', 'on']) + +function isTitleCase(value: string): boolean { + // Strip a trailing parenthetical so "API Key (Optional)" is judged on both halves. + const words = value.replace(/[()]/g, ' ').trim().split(/\s+/) + return words.every((word, index) => { + const bare = word.replace(/[^A-Za-z]/g, '') + if (!bare) return true + if (index > 0 && MINOR_WORDS.has(bare.toLowerCase())) return true + return bare[0] === bare[0].toUpperCase() + }) +} + +/** First word capitalised, later words lowercase unless they are proper nouns. */ +function isSentenceCase(value: string, properNouns: string[]): boolean { + let rest = value + for (const noun of properNouns) rest = rest.split(noun).join('') + const words = rest.trim().split(/\s+/).slice(1) + return words.every((word) => { + const bare = word.replace(/[^A-Za-z]/g, '') + if (!bare) return true + return bare[0] === bare[0].toLowerCase() + }) +} + +describe('Integrations settings casing', () => { + describe('names are Title Case', () => { + const NAME_KEYS = [ + 'nhtsa', + 'carComplaints', + 'shopFinder', + 'livelink', + 'webhooks', + 'telegramInbound', + 'llmSection', + 'webhookToken', + 'llmBaseUrl', + 'llmModel', + 'llmApiKey', + 'provider', + 'apiLimits', + 'options', + ] + + it.each(NAME_KEYS)('%s', (key) => { + const value = integrations[key] + expect(value, `integrations.${key} is missing`).toBeTruthy() + expect(isTitleCase(value), `integrations.${key} = ${value}`).toBe(true) + }) + }) + + describe('toggle labels are sentence case', () => { + // Proper nouns keep their own capitalisation inside a sentence-case string. + const PROPER_NOUNS = ['NHTSA', 'CarComplaints', 'Telegram', 'Ask My Garage', 'ID'] + + const STATEMENT_KEYS = [ + 'enableNHTSA', + 'enableAutoCheck', + 'enableCarComplaints', + 'enableTelegramInbound', + 'enableLlmReceipt', + 'enableLlmAssistant', + ] + + it.each(STATEMENT_KEYS)('%s', (key) => { + const value = integrations[key] + expect(value, `integrations.${key} is missing`).toBeTruthy() + expect(isSentenceCase(value, PROPER_NOUNS), `integrations.${key} = ${value}`).toBe(true) + }) + }) + + it('the provider status column has both labels, so a chip can name the state', () => { + // The column rendered a bare Check / X lucide icon with no accessible name, + // so a screen reader announced an empty cell. Naming both states is what + // lets the icons be replaced with a labelled chip. + expect(integrations['statusActive']).toBeTruthy() + expect(integrations['statusInactive']).toBeTruthy() + }) +}) diff --git a/frontend/src/components/AnalyticsHelpModal.tsx b/frontend/src/components/AnalyticsHelpModal.tsx index 82c49576..839527b2 100644 --- a/frontend/src/components/AnalyticsHelpModal.tsx +++ b/frontend/src/components/AnalyticsHelpModal.tsx @@ -160,6 +160,17 @@ export default function AnalyticsHelpModal({ isOpen, onClose }: AnalyticsHelpMod + {/* Tires */} +
+

{t('vehicleHelp.tires.title')}

+
+

{t('vehicleHelp.tires.body')}

+

{t('vehicleHelp.tires.readinessLabel')} {t('vehicleHelp.tires.readinessDesc')}

+

{t('vehicleHelp.tires.independentLabel')} {t('vehicleHelp.tires.independentDesc')}

+

{t('vehicleHelp.tires.notExportedLabel')} {t('vehicleHelp.tires.notExportedDesc')}

+
+
+ {/* Export Features */}

{t('vehicleHelp.export.title')}

diff --git a/frontend/src/components/LineItemEditor.tsx b/frontend/src/components/LineItemEditor.tsx index c4bc0a1c..10f8e17e 100644 --- a/frontend/src/components/LineItemEditor.tsx +++ b/frontend/src/components/LineItemEditor.tsx @@ -223,8 +223,6 @@ export default function LineItemEditor({ type="number" value={item.cost ?? ''} onChange={(e) => onChange(index, 'cost', e.target.value ? parseFloat(e.target.value) : undefined)} - min="0" - step="0.01" placeholder="0.00" disabled={disabled} className="w-full pl-7 pr-3 py-2 border border-garage-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary bg-garage-bg text-garage-text" @@ -385,7 +383,6 @@ export default function LineItemEditor({ const km = val != null ? u.distance.toCanonical(val) ?? undefined : undefined handleReminderFieldChange('due_mileage_km', km) }} - min="1" /* One example for every account. R5 calls a placeholder an EXAMPLE value with nothing canonical to convert, and it was still being chosen by the diff --git a/frontend/src/components/ServiceVisitForm.tsx b/frontend/src/components/ServiceVisitForm.tsx index 1ab3ae31..21fe496a 100644 --- a/frontend/src/components/ServiceVisitForm.tsx +++ b/frontend/src/components/ServiceVisitForm.tsx @@ -344,6 +344,120 @@ export default function ServiceVisitForm({ const mapSuppliesUsedForSubmit = (item: ServiceVisitFormLineItem): SupplyUsedEntry[] => convertSupplyUsages(item.supplies_used ?? [], suppliesById, system, displayToCanonical) + /** + * The replacement for the form's native constraints. + * + * `noValidate` on the form means the browser no longer enforces `required`, + * `min` or `step`, so each has an equivalent here. This is not optional + * belt-and-braces: without the date check, `noValidate` would let a blank + * date reach the API, turning a silent no-op into a silent bad write. + * + * Returns a field-keyed map so errors render inline on the offending + * control via ``, rather than as one banner that does not + * say which input is wrong. + */ + const validateFields = (): Record => { + const errors: Record = {} + + if (!formData.date) { + errors.date = t('common:required') + } + + // `min="0"` equivalents. Checked with `< 0` rather than `!(x >= 0)` so an + // empty optional field stays valid. + const nonNegative: [keyof ServiceVisitFormData, string][] = [ + ['odometer_km', 'odometer_km'], + ['engine_hours', 'engine_hours'], + ['tax_amount', 'tax_amount'], + ['shop_supplies', 'shop_supplies'], + ['misc_fees', 'misc_fees'], + ] + for (const [field, key] of nonNegative) { + const raw = formData[field] + if (raw === undefined || raw === null || raw === '') continue + const value = typeof raw === 'string' ? parseFloat(raw) : (raw as number) + if (Number.isNaN(value)) { + errors[key] = t('common:mustBeANumber') + } else if (value < 0) { + errors[key] = t('common:mustNotBeNegative') + } + } + + // `step` equivalents. The browser rejected a value that was not a whole + // multiple of the step; the user-visible meaning is a decimal-place limit, + // so that is what the message says. + const decimals: [keyof ServiceVisitFormData, string, number][] = [ + ['odometer_km', 'odometer_km', 1], + ['engine_hours', 'engine_hours', 1], + ['tax_amount', 'tax_amount', 2], + ['shop_supplies', 'shop_supplies', 2], + ['misc_fees', 'misc_fees', 2], + ] + for (const [field, key, places] of decimals) { + if (errors[key]) continue + const raw = formData[field] + if (raw === undefined || raw === null || raw === '') continue + const text = String(raw) + const fraction = text.includes('.') ? text.split('.')[1].length : 0 + if (fraction > places) { + errors[key] = t('common:tooManyDecimals', { count: places }) + } + } + + return errors + } + + /** + * The nested components' native constraints, enforced here. + * + * `LineItemEditor` and `SupplyUsedPicker` render INSIDE this form, so their + * `min`/`step` attributes abort ITS submit -- and they sit in per-line-item + * sections that may be collapsed or scrolled away, which is precisely where + * the browser cannot focus the offending control and the failure is silent. + * They are also the fields a user is most likely to fumble. + * + * Their data lives in this component's `formData.line_items`, so the check + * belongs here rather than in components that have no error-display path. + * Reported through `setError` as a banner, matching how the existing + * description and inspection checks already report per-line-item problems. + */ + const validateLineItems = (): string | null => { + for (const [i, item] of formData.line_items.entries()) { + const n = i + 1 + if (item.cost !== undefined && item.cost !== null) { + if (Number.isNaN(item.cost)) return t('service.lineItemCostInvalid', { number: n }) + if (item.cost < 0) return t('service.lineItemCostNegative', { number: n }) + const text = String(item.cost) + if (text.includes('.') && text.split('.')[1].length > 2) { + return t('service.lineItemCostDecimals', { number: n }) + } + } + // The replaced `min="1"` sat on the DISPLAY value, while + // `due_mileage_km` is canonical km (LineItemEditor converts on change). + // Comparing canonical km against a bare 1 would silently loosen the + // floor for an imperial account from 1 mi to 1 km, so the threshold is + // converted into the same space the constraint was written in. + const km = item.reminderDraft?.due_mileage_km + const minimumKm = u.distance.toCanonical(1) ?? 1 + if (km != null && !Number.isNaN(Number(km)) && Number(km) < minimumKm) { + return t('service.reminderIntervalTooSmall', { number: n }) + } + for (const usage of item.supplies_used ?? []) { + if (Number.isNaN(usage.quantity) || usage.quantity < 0) { + return t('service.supplyQuantityInvalid', { number: n }) + } + // The replaced `step` was `'1'` for count-type supplies and `'0.01'` + // otherwise, so a count could not take a fraction. The backend only + // enforces `gt=0` (schemas/supply.py:75), so dropping this check + // rather than moving it would let "2.5 oil filters" through. + if (suppliesById.get(usage.supply_id)?.unit_type === 'count' && !Number.isInteger(usage.quantity)) { + return t('service.supplyQuantityWholeNumber', { number: n }) + } + } + } + return null + } + const handleSubmit = async (e: SyntheticEvent) => { e.preventDefault() setError(null) @@ -358,7 +472,21 @@ export default function ServiceVisitForm({ return } - // Validate + // Validate. Field-level constraints first: these replace the native + // `required`/`min`/`step` attributes the form no longer carries, and they + // render inline on the control rather than as a banner. + const fieldLevel = validateFields() + if (Object.keys(fieldLevel).length > 0) { + setFieldErrors(fieldLevel) + return + } + + const lineItemProblem = validateLineItems() + if (lineItemProblem) { + setError(lineItemProblem) + return + } + const emptyDescriptions = formData.line_items.some((item) => !item.description.trim()) if (emptyDescriptions) { setError(t('service.allLineItemsNeedDescription')) @@ -531,7 +659,16 @@ export default function ServiceVisitForm({ } > -
+ {error && (
{tires.length === 0 && ( )} + {/* Mounted and stored are separate sections, not one list sorted by + position. "In storage" is a state a tire spends half the year in + now, and a stored tire mixed into the corner list reads as a corner + whose label failed to render. The heading only appears when there is + something in it, so a single-set owner sees exactly what they saw + before this release. */} + {storedTires.length > 0 && ( +

{t('tireList.onTheVehicle')}

+ )} +
- {tires.map((tire: Tire) => ( + {mountedTires.map((tire: Tire) => ( {/* A full-bleed sibling button, which is a fourth clickable-card shape in this codebase and deliberately so. `Card interactive` @@ -403,14 +860,14 @@ export default function TireList({ vin }: TireListProps) { type="button" className="ui-focus-ring absolute inset-0 rounded-card" aria-label={t('tireList.historyOpen', { - position: positionLabels[tire.position], + position: labelFor(tire.position), })} onClick={() => setHistoryTireId(tire.id)} />
- {positionLabels[tire.position]} + {labelFor(tire.position)} {tire.below_threshold && ( @@ -453,26 +910,540 @@ export default function TireList({ vin }: TireListProps) { {tire.pressure_kpa != null ? u.pressure.format(num(tire.pressure_kpa)) : '—'}
{t('tireList.projection')}
-
- {tire.projected_km_remaining != null - ? `~${u.distance.format(num(tire.projected_km_remaining))}` - : '—'} - {tire.projected_wear_date - ? ` · ${formatDateForDisplay(tire.projected_wear_date)}` - : ''} -
+
{wearSummary(tire)}
+ {/* Distance on tire belongs on the MOUNTED card, not only on the + stored one. It is the number this whole release exists to + make correct, and leaving it off the card people actually + look at made the feature invisible on real data. */} +
{t('tireList.distanceOnTire')}
+
{distanceSummary(tire)}
+
+ + {/* Dismount, not delete. Taking a tire off for the season keeps + every reading and mount period; the destructive action stays + in the edit drawer where a mis-tap cannot reach it. */} + + {/* Retire sits beside Dismount and not beside Delete, because + the mis-tap this arrangement guards against is the one that + actually happens: reaching for Delete when you mean "I + replaced this tire". The drawer it opens IS the confirmation + step, and it explains the difference. */} + +
+ + ))} +
+ + {storedTires.length > 0 && ( + <> +

{t('tireList.inStorageHeading')}

+ {storedGroups.map((group) => ( +
+ {/* The set's own name, so four winter tires read as a set rather + than as four unrelated cards. Only rendered when more than one + group exists: a single-set owner should see exactly what they + saw before sets existed. */} + {storedGroups.length > 1 && ( +

+ {group.label} +

+ )} +
+ {group.tires.map((tire: Tire) => ( + +
+
+
{labelFor(tire.position)}
+
+ {[tire.brand, tire.model_name, tire.size].filter(Boolean).join(' · ') || '—'} +
+
+ openEditForm(tire)} + /> +
+
+
{t('tireList.tread')}
+
+ {tire.tread_depth_mm != null ? u.tread.format(num(tire.tread_depth_mm)) : '—'} +
+
{t('tireList.distanceOnTire')}
+
{distanceSummary(tire)}
+
+
+ + + {/* A set can wear out and be replaced without ever going + back on the vehicle, so retire has to reach a stored + tire too. */} + +
+
+ ))} +
+
+ ))} + + )} + + {/* Mount and dismount are their own drawers, not confirm dialogs: both + carry an odometer reading, and that reading is what makes the tire's + distance computable at all. A confirm with no field would produce a + mount period with no bound, which reports "not recorded yet" forever + -- the exact dead end this release exists to get out of. */} + setMountTireId(null)} + title={t('tireList.mountTitle')} + icon={Gauge} + width="xs" + closeLabel={t('common:close')} + footer={ + <> + + + + } + > +
+ +
+ {freePositions.map((position) => ( + setMountPosition(position)} + > + {position} + + ))} +
+
+ + setMountOdometer(e.target.value)} + /> + +
+
+ + setDismountTireId(null)} + title={t('tireList.dismountTitle')} + icon={Gauge} + width="xs" + closeLabel={t('common:close')} + footer={ + <> + + + } + > +
+

{t('tireList.dismountHint')}

+ + setDismountOdometer(e.target.value)} + /> + +
+
+ + {/* Retire is its own drawer for the same reason dismount is: it closes a + mount period, and a period closed without an odometer is one this + tire's distance can never be computed from. The hint is the whole + point of the drawer, though -- it is the only place the app says out + loud that retiring and deleting are different. */} + setRetireTireId(null)} + title={t('tireList.retireTitle')} + icon={Gauge} + width="xs" + closeLabel={t('common:close')} + footer={ + <> + + + + } + > +
+

{t('tireList.retireHint')}

+ + setRetireOdometer(e.target.value)} + /> + +
+
+ + setRotateOpen(false)} + title={t('tireList.rotateTitle')} + icon={RotateCw} + width="sm" + closeLabel={t('common:close')} + footer={ + <> + + + + } + > +
+

{t('tireList.rotateHint')}

+
+ + {t('tireList.rotatePattern')} + + {/* Named by drivetrain rather than drawn as a diagram: the user + knows what they drive, and a four-arrow diagram is the part of + every tire-rotation chart people misread. The resulting moves + are shown below in full, so the choice is checkable without + trusting the name. */} +
+ {ROTATION_PATTERN_IDS.map((id) => ( + setRotatePattern(id)} + > + {rotationPatternLabels[id].name} + + ))} +
+

{rotationPatternLabels[rotatePattern].desc}

+
+
+ + {t('tireList.rotatePreview')} + +
    + {ROTATION_CORNERS.map((corner) => ( + + ))} +
+
+ + setRotateOdometer(e.target.value)} + /> + +
+
+ + { + setSetsOpen(false) + setFittingSetId(null) + setRenamingSetId(null) + }} + title={t('tireList.setsTitle')} + icon={Layers} + width="sm" + closeLabel={t('common:close')} + footer={ + + } + > +
+

{t('tireList.setsHint')}

+ + {tireSets.length === 0 ? ( + openReadingForm(tire)} + title={t('tireList.setsEmpty')} + description={t('tireList.setsEmptyHint')} + /> + ) : ( +
    + {tireSets.map((tireSet) => ( +
  • + {renamingSetId === tireSet.id ? ( +
    +
    + + setRenameValue(e.target.value)} + /> + +
    + + +
    + ) : ( + <> +
    +
    +
    {tireSet.name}
    +
    + {t('tireList.setMembership', { + total: tireSet.tire_ids.length, + mounted: tireSet.mounted_count, + })} +
    +
    + handleDeleteSet(tireSet.id)} + /> +
    +
    + {/* Fitting an empty set is a 409 the user cannot act + on from here, so the control is not offered until + the set has something in it. */} + + +
    + + )} + + {fittingSetId === tireSet.id && ( +
    +

    {t('tireList.setFitHint')}

    + + setFitOdometer(e.target.value)} + /> + +
    + + +
    +
    + )} +
  • + ))} +
+ )} + +
+
+ + setNewSetName(e.target.value)} + /> + +
+ - - ))} -
+
+
+ {t('common:cancel')} - @@ -555,8 +1528,52 @@ export default function TireList({ vin }: TireListProps) { ) })} + {/* Storage is a sixth destination, not a sixth corner. It is the + only way to enter a tire you own but are not fitting today, + which is every second seasonal set, and without it a tire + could only come into the app by being mounted onto a corner + it then had to be dismounted from. */} + setForm({ ...form, position: null }) : undefined + } + > + {t('tireList.inStorage')} +
+ {editingTireId === null && form.position == null && ( +

{t('tireList.storedHint')}

+ )}
+ {/* Membership is editable on an EXISTING tire only, because + `POST /tires` forbids a `set_id`. Hidden rather than disabled + when adding: a control that cannot be used yet reads as broken, + and the tire can be filed the moment it is saved. */} + {editingTireId !== null && tireSets.length > 0 && ( +
+ + {t('tireList.setLabel')} + +
+ setForm({ ...form, set_id: null })} + > + {t('tireList.setNone')} + + {tireSets.map((tireSet) => ( + setForm({ ...form, set_id: tireSet.id })} + > + {tireSet.name} + + ))} +
+
+ )}
setReadingTireId(null)} title={t('tireList.readingTitle', { - position: readingTire ? positionLabels[readingTire.position] : '', + position: readingTire ? labelFor(readingTire.position) : '', })} icon={Gauge} width="xs" @@ -684,7 +1701,7 @@ export default function TireList({ vin }: TireListProps) { open={historyTire !== null} onClose={() => setHistoryTireId(null)} title={t('tireList.historyTitle', { - position: historyTire ? positionLabels[historyTire.position] : '', + position: historyTire ? labelFor(historyTire.position) : '', })} icon={Gauge} width="sm" diff --git a/frontend/src/components/__tests__/NoMovementSignalNotice.test.tsx b/frontend/src/components/__tests__/NoMovementSignalNotice.test.tsx new file mode 100644 index 00000000..b2ff4564 --- /dev/null +++ b/frontend/src/components/__tests__/NoMovementSignalNotice.test.tsx @@ -0,0 +1,105 @@ +/** + * The notice that keeps `contact` mode honest. + * + * Deciding WHICH devices cannot have their movement read needs their parameter + * keys, so the backend decides and this renders the answer. What is left to + * test here is that it renders that answer and never a guess of its own: the + * previous version inferred the cohort from `last_movement_at == null`, which + * migration 098 makes true for every device that exists. + */ + +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import NoMovementSignalNotice from '../livelink/NoMovementSignalNotice' +import type { LiveLinkDevice } from '@/types/livelink' + +// The shared setup mocks `t` as `(key) => key`, which discards interpolation -- +// and the device names ARE the interpolation here, so under that mock a notice +// naming the wrong vehicle is indistinguishable from one naming the right one. +// Overridden for this file only, so the names become assertable. +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + options?.devices ? `${key}:${String(options.devices)}` : key, + i18n: { language: 'en', changeLanguage: () => Promise.resolve() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})) + +const device = (overrides: Partial = {}): LiveLinkDevice => + ({ + id: 1, + device_id: 'aabbccddeeff', + label: null, + vin: '1HGCM82633A123456', + enabled: true, + last_seen: '2026-09-03T12:00:00Z', + last_movement_at: null, + movement_unreadable: false, + created_at: '2025-08-01T12:00:00Z', + updated_at: null, + ecu_status: 'online', + device_status: 'online', + has_device_token: false, + sd_backfill_enabled: false, + ...overrides, + }) as LiveLinkDevice + +const notice = () => screen.queryByText('modal.livelink.noMovementSignal') + +describe('NoMovementSignalNotice', () => { + it('names a device the backend found unreadable', () => { + render() + expect(notice()).toBeTruthy() + }) + + it('stays quiet for a device that has simply not moved yet', () => { + // The day-one case, and the whole reason this component stopped deciding + // for itself: no movement is on record, because the column that records it + // was created by the migration that shipped it. The backend can see the + // device is publishing only its parked heartbeat. This component cannot, + // and must not guess. + render( + + ) + expect(notice()).toBeNull() + }) + + it('names only the unreadable device when the fleet is mixed', () => { + render( + + ) + expect(notice()).toBeTruthy() + expect(screen.getByText(/Mirage/)).toBeTruthy() + expect(screen.queryByText(/Ram/)).toBeNull() + }) + + it('falls back to the device id when a device has no label', () => { + render( + + ) + expect(screen.getByText(/aabbccddeeff/)).toBeTruthy() + }) + + it('says nothing when there are no devices at all', () => { + render() + expect(notice()).toBeNull() + }) + + it('renders the same output twice for the same input', () => { + const devices = [device({ movement_unreadable: true })] + const first = render().container.innerHTML + const second = render().container.innerHTML + expect(first).toBe(second) + }) +}) diff --git a/frontend/src/components/__tests__/ServiceVisitForm.lineItemValidation.test.tsx b/frontend/src/components/__tests__/ServiceVisitForm.lineItemValidation.test.tsx new file mode 100644 index 00000000..c2248871 --- /dev/null +++ b/frontend/src/components/__tests__/ServiceVisitForm.lineItemValidation.test.tsx @@ -0,0 +1,237 @@ +/** + * The nested half of "native constraints are replaced, not just disabled". + * + * `ServiceVisitForm.validation.test.tsx` MOCKS `LineItemEditor`, so neither its + * behavioural cases nor its structural sweep can see the per-line-item inputs + * at all. Those are the ones that matter most: they live in sections that can + * be collapsed or scrolled away, which is exactly where a native constraint + * aborts a submit with nothing shown, and they were missed by two hand-written + * revisions of the fix. + * + * `validateLineItems` is what replaced `min="0"`, `step="0.01"`, `min="1"` and + * `SupplyUsedPicker`'s unit-dependent `step`. It had no test of any kind: six + * branches, and the only thing standing between a removed browser constraint + * and a bad write. Each branch gets one here, and each asserts BOTH a visible + * message and that nothing was posted, because a form that complains and posts + * anyway is the failure this whole change exists to avoid. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, fireEvent, waitFor } from '@testing-library/react' +import { render } from '../../__tests__/test-utils' +import ServiceVisitForm from '../ServiceVisitForm' +import { useSupplies } from '../../hooks/queries/useSupplies' +import type { Supply } from '../../types/supplies' + +const mockedApiGet = vi.fn().mockResolvedValue({ data: { items: [] } }) +const mockedApiPost = vi.fn().mockResolvedValue({ data: {} }) +const mockedApiPut = vi.fn().mockResolvedValue({ data: {} }) + +vi.mock('../../services/api', () => ({ + default: { + get: (...args: unknown[]) => mockedApiGet(...args), + post: (...args: unknown[]) => mockedApiPost(...args), + put: (...args: unknown[]) => mockedApiPut(...args), + }, +})) + +vi.mock('../../hooks/useUnitPreference', async () => { + const { METRIC_UNITS } = await import('@/__tests__/factories') + return { + useUnitPreference: () => ({ + system: 'metric', + showBoth: false, + units: METRIC_UNITS, + gallonStandard: 'us', + }), + } +}) + +vi.mock('../../hooks/useCurrencyPreference', () => ({ + useCurrencyPreference: () => ({ + currencyCode: 'USD', + locale: 'en-US', + formatCurrency: () => '$0.00', + }), +})) + +/** A COUNT supply, so the whole-number branch is reachable. */ +const OIL_FILTER: Supply = { + id: 1, + name: 'Oil Filter', + unit_type: 'count', + avg_unit_cost: '10.00', + on_hand: '20', + is_active: true, + is_negative: false, + created_at: '2026-01-01T00:00:00', + category: null, + notes: null, + part_number: null, + updated_at: null, + vin: null, +} + +vi.mock('../../hooks/queries/useSupplies', () => ({ + useSupplies: vi.fn(() => ({ + data: { supplies: [OIL_FILTER], total: 1 }, + isSuccess: true, + isLoading: false, + isError: false, + })), +})) + +vi.mock('../VendorSearch', () => ({ default: () =>
})) +vi.mock('../ServiceVisitAttachmentUpload', () => ({ + default: () =>
, +})) +vi.mock('../ServiceVisitAttachmentList', () => ({ + default: () =>
, +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +const DEFAULT_PROPS = { vin: 'TEST123', onClose: vi.fn(), onSuccess: vi.fn() } + +const drawerForm = (): HTMLFormElement => + screen.getByRole('dialog').querySelector('form') as HTMLFormElement + +/** The line item needs a description, or its own check fires first. */ +const describeTheWork = () => + fireEvent.change(screen.getByPlaceholderText('lineItemEditor.misc.selectCategoryFirst'), { + target: { value: 'Oil change' }, + }) + +const costField = (): HTMLInputElement => + screen.getByText('lineItemEditor.cost').parentElement?.querySelector( + 'input[type="number"]' + ) as HTMLInputElement + +const addSupplyRow = () => fireEvent.click(screen.getByRole('button', { name: /suppliesAddRow/ })) + +const quantityField = (): HTMLInputElement => + screen.getByRole('spinbutton', { name: 'service.suppliesQuantity' }) as HTMLInputElement + +/** Submit and assert nothing reached the API. */ +const submitAndExpectRefusal = async (message: RegExp) => { + fireEvent.submit(drawerForm()) + expect(await screen.findByText(message)).toBeTruthy() + await waitFor(() => expect(mockedApiPost).not.toHaveBeenCalled()) +} + +describe('ServiceVisitForm — the line-item constraints that replaced native ones', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useSupplies).mockReturnValue({ + data: { supplies: [OIL_FILTER], total: 1 }, + isSuccess: true, + isLoading: false, + isError: false, + } as unknown as ReturnType) + }) + + it('the real nested inputs carry no native constraint either', () => { + // The structural sweep in ServiceVisitForm.validation.test.tsx runs with + // LineItemEditor MOCKED, so it enumerates a tree these inputs are not in. + // This one renders them for real. `required` is a bare JSX boolean with no + // `=`, which is how the visit date survived two inventories of this form. + render() + describeTheWork() + addSupplyRow() + + const form = drawerForm() + const offenders = Array.from(form.querySelectorAll('input, select, textarea')).filter((el) => + ['required', 'min', 'max', 'step', 'pattern', 'minlength', 'maxlength'].some((a) => + el.hasAttribute(a) + ) + ) + expect(offenders.map((el) => el.id || el.getAttribute('aria-label'))).toEqual([]) + }) + + it('refuses a negative line-item cost', async () => { + // Replaced `min="0"` at LineItemEditor:226. Under the native rule this + // aborted submit with no message, and the row may not even be on screen. + render() + describeTheWork() + fireEvent.change(costField(), { target: { value: '-5' } }) + + await submitAndExpectRefusal(/lineItemCostNegative/) + }) + + it('refuses a third decimal place on a line-item cost', async () => { + // Replaced `step="0.01"`. The likelier of the two in practice: a negative + // cost is rare, a third decimal is not, and `stepMismatch` showed nothing. + render() + describeTheWork() + fireEvent.change(costField(), { target: { value: '12.005' } }) + + await submitAndExpectRefusal(/lineItemCostDecimals/) + }) + + it('treats unparseable text as no cost, rather than throwing or posting NaN', async () => { + // The specific failure `SupplyHistoryModal` records is a THROW: RHF's own + // `min` coerces with unary `+` and blows up on the symbol + // `registerDecimal` emits for unparseable text. Nothing here throws, and + // nothing garbage reaches the API. + // + // Note on `validateLineItems`' `Number.isNaN(item.cost)` branch: it has no + // test because it has no reachable input. `type="number"` sanitises 'abc' + // to '' in jsdom and in browsers, and the handler maps '' to `undefined` + // before the validator ever sees it. It is kept as cheap defence for + // paste and programmatic paths rather than deleted to satisfy a coverage + // rule, and this note is here so that is a recorded decision rather than + // an oversight the next reader has to re-derive. + render() + describeTheWork() + fireEvent.change(costField(), { target: { value: 'abc' } }) + + fireEvent.submit(drawerForm()) + + await waitFor(() => expect(mockedApiPost).toHaveBeenCalled()) + const body = mockedApiPost.mock.calls.at(-1)?.[1] as { + line_items: { cost?: number | null }[] + } + expect(body.line_items[0].cost ?? null).toBeNull() + }) + + it('refuses a negative supply quantity', async () => { + // Replaced SupplyUsedPicker's `min="0"`, which is the most deeply nested + // control in this form. + render() + describeTheWork() + addSupplyRow() + fireEvent.change(quantityField(), { target: { value: '-2' } }) + + await submitAndExpectRefusal(/supplyQuantityInvalid/) + }) + + it('refuses a fractional quantity of a counted supply', async () => { + // Replaced SupplyUsedPicker's unit-dependent `step`, which was `'1'` for a + // count. The backend enforces only `gt=0`, so dropping this check rather + // than moving it would let "2.5 oil filters" through. + render() + describeTheWork() + addSupplyRow() + fireEvent.change(quantityField(), { target: { value: '2.5' } }) + + await submitAndExpectRefusal(/supplyQuantityWholeNumber/) + }) + + it('posts a line item whose numbers are all fine', async () => { + // The positive control. Without it every test above is satisfied by a form + // that refuses everything. + render() + describeTheWork() + fireEvent.change(costField(), { target: { value: '12.34' } }) + addSupplyRow() + fireEvent.change(quantityField(), { target: { value: '2' } }) + + fireEvent.submit(drawerForm()) + + await waitFor(() => expect(mockedApiPost).toHaveBeenCalled()) + const body = mockedApiPost.mock.calls.at(-1)?.[1] as { + line_items: { cost: number; supplies_used: { quantity: number }[] }[] + } + expect(body.line_items[0].cost).toBe(12.34) + expect(body.line_items[0].supplies_used[0].quantity).toBe(2) + }) +}) diff --git a/frontend/src/components/__tests__/ServiceVisitForm.validation.test.tsx b/frontend/src/components/__tests__/ServiceVisitForm.validation.test.tsx new file mode 100644 index 00000000..1bbe7e4a --- /dev/null +++ b/frontend/src/components/__tests__/ServiceVisitForm.validation.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import userEvent from '@testing-library/user-event' +import { render, screen } from '../../__tests__/test-utils' +import ServiceVisitForm from '../ServiceVisitForm' +import api from '../../services/api' + +/** + * Native constraints on this form abort submit silently. + * + * `` had no `noValidate`, and the inputs inside + * it carry `required`, `min` and `step`. When a constraint fails the browser + * aborts the submit and tries to focus the offending control -- but the + * per-line-item fields live in sections that may be collapsed or scrolled + * away, where the browser cannot focus them, so nothing is shown and Save + * appears to do nothing at all. + * + * The fix is `noValidate` plus real validation. `noValidate` ALONE would be + * worse than the bug: `required` on the visit date (`:553`) is the only thing + * currently stopping a blank date from reaching the API, and `handleSubmit` + * checked only line-item descriptions and inspection results. That would turn + * a silent no-op into a silent bad write. + * + * So each test below seeds a value that the removed native constraint used to + * reject, and asserts two things: a visible error, and that no request was + * sent. Asserting only the error would pass against a form that shows a + * message and posts anyway. + */ + +vi.mock('../../services/api', () => ({ + default: { + get: vi.fn().mockResolvedValue({ data: { items: [] } }), + post: vi.fn().mockResolvedValue({ data: {} }), + put: vi.fn().mockResolvedValue({ data: {} }), + }, +})) + +vi.mock('../../hooks/useUnitPreference', async () => { + const { METRIC_UNITS } = await import('@/__tests__/factories') + return { + useUnitPreference: () => ({ + system: 'metric', + showBoth: false, + units: METRIC_UNITS, + gallonStandard: 'us', + }), + } +}) + +vi.mock('../../hooks/useCurrencyPreference', () => ({ + useCurrencyPreference: () => ({ + currencyCode: 'USD', + locale: 'en-US', + formatCurrency: () => '$0.00', + }), +})) + +vi.mock('../VendorSearch', () => ({ default: () =>
})) +vi.mock('../LineItemEditor', () => ({ default: () =>
})) +vi.mock('../ServiceVisitAttachmentUpload', () => ({ + default: () =>
, +})) +vi.mock('../ServiceVisitAttachmentList', () => ({ + default: () =>
, +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +const DEFAULT_PROPS = { vin: 'TEST123', onClose: vi.fn(), onSuccess: vi.fn() } + +const save = async () => { + const user = userEvent.setup() + // The submit button lives OUTSIDE the and is bound to it with + // `form="service-visit-form"`, so it is found by attribute rather than by + // an accessible name that the i18n mock renders as a raw key. + const button = document.querySelector( + 'button[type="submit"][form="service-visit-form"]' + ) as HTMLButtonElement + expect(button).toBeTruthy() + await user.click(button) + return user +} + +describe('ServiceVisitForm – native constraints are replaced, not just disabled', () => { + beforeEach(() => vi.clearAllMocks()) + + it('the form does not defer to browser validation', () => { + render() + // The form renders inside a portalled drawer, so it is on document, not + // inside RTL's container. + const form = document.querySelector('form#service-visit-form') as HTMLFormElement + expect(form).toBeTruthy() + expect(form.noValidate).toBe(true) + }) + + it('carries no native constraint attributes that could abort a submit', () => { + render() + // The form renders inside a portalled drawer, so it is on document, not + // inside RTL's container. + const form = document.querySelector('form#service-visit-form') as HTMLFormElement + // Enumerated by ATTRIBUTE across every control in the rendered form, + // rather than by a list of fields someone remembered. `required` is a + // bare boolean in JSX and has no `=`, which is how it survived two + // hand-written inventories of this form. + const offenders = Array.from(form.querySelectorAll('input, select, textarea')).filter((el) => + ['required', 'min', 'max', 'step', 'pattern', 'minlength', 'maxlength'].some((a) => + el.hasAttribute(a) + ) + ) + expect(offenders.map((el) => `${el.id || el.getAttribute('name')}`)).toEqual([]) + }) + + it('rejects a blank date with a visible error and sends nothing', async () => { + render() + const user = userEvent.setup() + const date = screen.getByLabelText(/date/i) as HTMLInputElement + await user.clear(date) + + await save() + + expect(await screen.findByText(/required|common:required/i)).toBeTruthy() + expect(api.post).not.toHaveBeenCalled() + }) + + it('rejects a negative odometer with a visible error and sends nothing', async () => { + render() + const user = userEvent.setup() + const odo = screen.getByLabelText(/mileage/i) as HTMLInputElement + await user.clear(odo) + await user.type(odo, '-5') + + await save() + + expect(await screen.findByText(/negative|atLeast|min/i)).toBeTruthy() + expect(api.post).not.toHaveBeenCalled() + }) + + it('rejects too many decimal places with a visible error and sends nothing', async () => { + // The `step` case. More likely in practice than a negative number: a user + // typing a third decimal into a currency field was silently blocked with + // no message at all. + render() + const user = userEvent.setup() + const tax = screen.getByLabelText(/tax/i) as HTMLInputElement + await user.clear(tax) + await user.type(tax, '12.005') + + await save() + + expect(await screen.findByText(/decimal|step|precision/i)).toBeTruthy() + expect(api.post).not.toHaveBeenCalled() + }) + + it('does not reject a form whose fields are all valid', async () => { + // Guards the guard: without this, a validator that rejects everything + // would pass every test above. + // + // A pristine form cannot reach `api.post` -- it starts with one empty line + // item, and the pre-existing description check blocks it, with + // `LineItemEditor` mocked out here so the description cannot be filled in. + // So this asserts the thing actually under test: the new FIELD-level + // validation passes, and the only complaint is the older form-level one. + render() + await save() + + expect(await screen.findByText(/allLineItemsNeedDescription/i)).toBeTruthy() + // None of the field-level messages this file added. + expect(screen.queryByText(/mustNotBeNegative|tooManyDecimals|mustBeANumber/i)).toBeNull() + }) +}) diff --git a/frontend/src/components/__tests__/TireAnalyticsSection.test.tsx b/frontend/src/components/__tests__/TireAnalyticsSection.test.tsx new file mode 100644 index 00000000..27237162 --- /dev/null +++ b/frontend/src/components/__tests__/TireAnalyticsSection.test.tsx @@ -0,0 +1,379 @@ +/** + * The tire analytics section, and mostly its empty states. + * + * On the instance that asked for this feature, most of the page IS the empty + * states: two tires, two readings, zero readings carrying an odometer. So + * getting them wrong is the main risk in a read-only feature, and every row of + * spec B's state table is a case here. + * + * **Every assertion below seeds the data that makes it meaningful.** A test + * that passes because the section rendered nothing against an empty response + * is not a test of an empty state, it is a test of an empty response. + * + * Imperial throughout, so the figures asserted are the TRANSFORMED ones. A + * dimensionless assertion would pass whether or not the conversion ran, which + * is how a units regression hides. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type React from 'react' +import { render, screen, waitFor } from '@testing-library/react' + +const apiGet = vi.fn() +vi.mock('@/services/api', () => ({ default: { get: (...args: unknown[]) => apiGet(...args) } })) + +/* Recharts renders nothing under jsdom: `ResponsiveContainer` measures its + * parent, which has zero width, so the real chart produces an empty legend and + * no series. Mocked down to the one thing worth asserting -- WHICH tires get a + * series -- because a tire that draws no line still costs a legend entry. */ +vi.mock('recharts', () => { + const passthrough = ({ children }: { children?: React.ReactNode }) =>
{children}
+ return { + ResponsiveContainer: passthrough, + LineChart: passthrough, + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: () => null, + Legend: () => null, + Line: ({ dataKey }: { dataKey: string }) =>
{dataKey}
, + } +}) + +vi.mock('../../hooks/useUnitPreference', () => ({ + useUnitPreference: () => ({ + system: 'imperial', + showBoth: false, + gallonStandard: 'us', + units: { + consumption: 'mpg_us', + distance: 'mi', + length: 'ft', + mass: 'lb', + pressure: 'psi', + secondary_gallon: 'us', + speed: 'mph', + temperature: 'f', + torque: 'lbft', + tread: 'in32', + volume: 'gal_us', + }, + }), +})) + +import TireAnalyticsSection from '../TireAnalyticsSection' + +const VIN = '1HGCM82633A004352' + +const EMPTY_READINESS = { + total: 0, + can_trend: 0, + can_project: 0, + can_report_distance: 0, + under_minimum: 0, + needs_second_reading: 0, + needs_reading_odometer: 0, + needs_minimum_tread: 0, + needs_mount_odometer: 0, +} + +const reading = (day: number, tread: string | null, odometer: string | null = '10000') => ({ + id: day, + tire_id: 1, + vin: VIN, + position: 'FL', + recorded_at: `2026-0${day}-01`, + odometer_km: odometer, + tread_depth_mm: tread, + pressure_kpa: null, + notes: null, + created_at: '2026-01-01T00:00:00', +}) + +const tire = (over: Record = {}) => ({ + id: 1, + vin: VIN, + position: 'FL', + brand: 'Michelin', + model_name: null, + size: null, + dot_code: null, + set_id: null, + retired_on: null, + installed_date: null, + tread_depth_mm: '8.00', + pressure_kpa: null, + min_tread_mm: '2.00', + notes: null, + below_threshold: false, + projected_km_remaining: null, + projected_wear_date: null, + wear_status: 'insufficient_readings', + distance_km: null, + known_distance_km: null, + known_distance_since: null, + distance_status: 'no_periods', + blocking_period_ids: [], + mount_periods: [], + readings: [], + created_at: '2026-01-01T00:00:00', + ...over, +}) + +const respond = (over: Record = {}) => + apiGet.mockResolvedValue({ + data: { + readiness: { ...EMPTY_READINESS, ...(over.readiness as object) }, + tires: (over.tires as unknown[]) ?? [], + has_odometer_record: over.has_odometer_record ?? true, + }, + }) + +/** Render and wait for the one fetch to land. */ +const show = async () => { + render() + await waitFor(() => expect(apiGet).toHaveBeenCalled()) +} + +describe('TireAnalyticsSection', () => { + afterEach(() => vi.restoreAllMocks()) + beforeEach(() => respond()) + + it('renders nothing for a vehicle with no tires', async () => { + // B9: gated on data presence, so a boat gets no empty blocks. Not on + // vehicle type, which would exclude the trailers that do have tires. + const { container } = render() + await waitFor(() => expect(apiGet).toHaveBeenCalled()) + expect(container).toBeEmptyDOMElement() + }) + + it('renders for a vehicle that has tires', async () => { + // The pair to the test above. Without it "renders nothing" is satisfied by + // a component that renders nothing ever. + respond({ tires: [tire()], readiness: { total: 1 } }) + await show() + expect(screen.getByText('vehicle.tires.title')).toBeInTheDocument() + }) +}) + +describe('TireAnalyticsSection distance states', () => { + afterEach(() => vi.restoreAllMocks()) + beforeEach(() => respond()) + + it('never renders a spare as 0 km', async () => { + // The case spec B's own v1 got wrong: the all-SPARE path returned a + // running total of zero, so the one state that must never show a figure + // was the one that did. + respond({ tires: [tire({ distance_status: 'spare_only', distance_km: null })] }) + await show() + + expect(screen.queryByText(/0 mi/)).toBeNull() + expect(screen.getAllByText('vehicle.tires.inStorage').length).toBeGreaterThan(0) + }) + + it('renders the prompt, not a number, for a migrated tire', async () => { + // `nothing_bounded` is the state of EVERY tire the moment migration 097 + // runs, so this is the common case rather than an edge one. + respond({ tires: [tire({ distance_status: 'nothing_bounded', blocking_period_ids: [4] })] }) + await show() + + expect(screen.getAllByText(/vehicle\.tires\.actionMountOdometer/).length).toBeGreaterThan(0) + expect(screen.queryByText(/0 mi/)).toBeNull() + }) + + it('renders the measurable part of a partial history, with its date', async () => { + respond({ + tires: [ + tire({ + distance_status: 'incomplete', + known_distance_km: '19000', + known_distance_since: '2026-01-05', + distance_km: null, + }), + ], + }) + await show() + + // 19,000 km = 11,806 mi at zero decimals. The transformed figure, so a + // conversion that stopped running would fail this rather than pass it. + expect(screen.getByText(/11,806 mi/)).toBeInTheDocument() + }) + + it('renders a complete distance in the reader unit', async () => { + respond({ tires: [tire({ distance_status: 'complete', distance_km: '19000' })] }) + await show() + expect(screen.getByText('11,806 mi')).toBeInTheDocument() + }) +}) + +describe('TireAnalyticsSection wear states', () => { + afterEach(() => vi.restoreAllMocks()) + beforeEach(() => respond()) + + it('suppresses the legacy projection rather than labelling it', async () => { + // `unverified_mount_history` is the raw-delta path that reports 648,000 km + // of life to a two-set owner. An "estimate" badge does not communicate + // that a figure is structurally invalid rather than imprecise, so the + // number is withheld and the prompt takes its place. + respond({ + tires: [ + tire({ wear_status: 'unverified_mount_history', projected_km_remaining: '648000' }), + ], + }) + await show() + + // Asserted POSITIVELY, on the cell. The first version of this checked that + // "648" was absent, which 648,000 km never renders as in miles (402,650), + // so it passed whatever the code did. + const row = screen.getByText(/Michelin/).closest('tr') + expect(row).not.toBeNull() + expect(row?.textContent).toContain('vehicle.tires.actionMountOdometer') + expect(row?.textContent).not.toMatch(/~/) + }) + + it('renders a real projection in the reader unit', async () => { + respond({ + tires: [ + tire({ + wear_status: 'projected', + projected_km_remaining: '19000', + projected_wear_date: '2027-01-01', + }), + ], + }) + await show() + expect(screen.getByText(/~11,806 mi/)).toBeInTheDocument() + }) + + it('gives a null minimum its own wording, not "not enough readings"', async () => { + respond({ tires: [tire({ wear_status: 'no_minimum_set', min_tread_mm: null })] }) + await show() + + expect(screen.getAllByText(/vehicle\.tires\.actionMinimumTread/).length).toBeGreaterThan(0) + expect(screen.queryByText(/actionSecondReading/)).toBeNull() + }) +}) + +describe('TireAnalyticsSection readiness', () => { + afterEach(() => vi.restoreAllMocks()) + beforeEach(() => respond()) + + it('leads with the worn tires, whatever else is missing', async () => { + // Safety outranks data. A tire at or below its minimum is something to do + // today; a missing odometer is something to write down. + respond({ + tires: [tire({ below_threshold: true })], + readiness: { total: 1, under_minimum: 1, needs_mount_odometer: 9 }, + }) + await show() + + // Scoped to the "Next:" line rather than to the prompt text, which the + // per-tire table also renders for its own statuses. + expect( + screen.getByText(/nextAction: vehicle\.tires\.actionUnderMinimum/) + ).toBeInTheDocument() + }) + + it('otherwise names the prompt that unblocks the most tires', async () => { + respond({ + tires: [tire()], + readiness: { total: 4, needs_second_reading: 1, needs_mount_odometer: 3 }, + }) + await show() + + expect( + screen.getByText(/nextAction: vehicle\.tires\.actionMountOdometer/) + ).toBeInTheDocument() + }) + + it('says so when there is nothing left to record', async () => { + respond({ tires: [tire()], readiness: { total: 1, can_trend: 1, can_project: 1 } }) + await show() + expect(screen.getByText('vehicle.tires.allAnswered')).toBeInTheDocument() + }) + + it('explains a vehicle with no odometer reading once, not per tire', async () => { + // B3. An open period's upper bound is the vehicle's latest odometer + // record, so with none there is no distance for any fitted tire however + // complete its mount history. + respond({ tires: [tire(), tire({ id: 2 })], has_odometer_record: false }) + await show() + expect(screen.getAllByText('vehicle.tires.noOdometerRecord')).toHaveLength(1) + }) +}) + +describe('TireAnalyticsSection retired tires', () => { + afterEach(() => vi.restoreAllMocks()) + beforeEach(() => respond()) + + it('lists a retired tire and keeps the readiness block about the live ones', async () => { + // B10. A retired tire's final figures are the most complete data the app + // will ever hold about it, and it is also the tire nothing can be done + // about, so it is in the table and out of the counts. + respond({ + tires: [ + tire({ id: 1, brand: 'Live' }), + tire({ id: 2, brand: 'Gone', retired_on: '2026-02-01', position: null }), + ], + readiness: { total: 1, needs_second_reading: 1 }, + }) + await show() + + expect(screen.getByText(/Gone/)).toBeInTheDocument() + expect(screen.getByText(/vehicle\.tires\.retiredOn/)).toBeInTheDocument() + expect(screen.getByText('vehicle.tires.readinessSummary')).toBeInTheDocument() + }) + + it('renders no readiness block when every tire is retired', async () => { + respond({ + tires: [tire({ retired_on: '2026-02-01' })], + readiness: { total: 0 }, + }) + await show() + + expect(screen.queryByText('vehicle.tires.readinessTitle')).toBeNull() + expect(screen.getByText('vehicle.tires.tableTitle')).toBeInTheDocument() + }) +}) + +describe('TireAnalyticsSection tread trend', () => { + afterEach(() => vi.restoreAllMocks()) + beforeEach(() => respond()) + + it('says there is nothing to plot when no tire has two tread readings', async () => { + respond({ tires: [tire({ readings: [reading(1, '8.00')] })] }) + await show() + expect(screen.getByText('vehicle.tires.trendEmpty')).toBeInTheDocument() + }) + + it('plots a tire with two tread readings', async () => { + respond({ tires: [tire({ readings: [reading(1, '8.00'), reading(2, '7.00')] })] }) + await show() + expect(screen.queryByText('vehicle.tires.trendEmpty')).toBeNull() + }) + + it('does not plot two pressure-only readings', async () => { + // Since #152 a reading can carry a pressure and no tread. Two of those are + // two points on a chart with no y value. + respond({ tires: [tire({ readings: [reading(1, null), reading(2, null)] })] }) + await show() + expect(screen.getByText('vehicle.tires.trendEmpty')).toBeInTheDocument() + }) + + it('gives a pressure-only tire no series when another tire does plot', async () => { + // The test above cannot see this: with NO plottable tire the chart is + // hidden either way, so dropping the tread filter from the series list + // survived it. The difference is a legend entry for a tire that draws no + // line, and it only appears in a mix. + respond({ + tires: [ + tire({ id: 1, brand: 'Plots', readings: [reading(1, '8.00'), reading(2, '7.00')] }), + tire({ id: 2, brand: 'Pressure', readings: [reading(1, null), reading(2, null)] }), + ], + }) + await show() + + const series = screen.getAllByTestId('series').map((el) => el.textContent) + expect(series).toEqual(['FL - Plots']) + }) +}) diff --git a/frontend/src/components/__tests__/TireList.metric.test.tsx b/frontend/src/components/__tests__/TireList.metric.test.tsx index f4bf1baa..5d27497c 100644 --- a/frontend/src/components/__tests__/TireList.metric.test.tsx +++ b/frontend/src/components/__tests__/TireList.metric.test.tsx @@ -17,11 +17,27 @@ const useUpsertTireMock = vi.fn() const useAddTireReadingMock = vi.fn() const useDeleteTireMock = vi.fn() +// v3.3.0 split the single upsert into create-and-mount plus update, and added +// mount/dismount. The old `useUpsertTire` name is kept for the mock variable +// because every assertion below is about the payload a save produces, and that +// payload is now create-and-mount's. vi.mock('../../hooks/queries/useTires', () => ({ useTires: () => useTiresMock(), - useUpsertTire: () => useUpsertTireMock(), + useCreateAndMountTire: () => useUpsertTireMock(), + useCreateTire: () => useUpsertTireMock(), + useUpdateTire: () => useUpsertTireMock(), + useMountTire: () => useUpsertTireMock(), + useDismountTire: () => useUpsertTireMock(), + useRetireTire: () => useUpsertTireMock(), + useRotateTires: () => useUpsertTireMock(), useAddTireReading: () => useAddTireReadingMock(), useDeleteTire: () => useDeleteTireMock(), + // Sets are not this file's subject; it just has to render past them. + useTireSets: () => ({ data: { sets: [], total: 0 }, isLoading: false, error: null }), + useCreateTireSet: () => useUpsertTireMock(), + useUpdateTireSet: () => useUpsertTireMock(), + useDeleteTireSet: () => useUpsertTireMock(), + useMountTireSet: () => useUpsertTireMock(), })) vi.mock('@tanstack/react-query', () => ({ diff --git a/frontend/src/components/__tests__/TireList.rotation.test.tsx b/frontend/src/components/__tests__/TireList.rotation.test.tsx new file mode 100644 index 00000000..fadc0817 --- /dev/null +++ b/frontend/src/components/__tests__/TireList.rotation.test.tsx @@ -0,0 +1,470 @@ +/** + * Rotate, retire and create-into-storage: the three flows that had no UI. + * + * All three endpoints shipped with the mount-period model, all three had a + * query hook, and none of them had a caller. `useRotateTires`, `useRetireTire` + * and `useCreateTire` each had exactly zero consumers in `src/`, and there was + * not one `tireList.rotate*` or `tireList.retire*` translation key. The e2e + * suite exercised all three, which is why nothing caught it: it drove them + * through the API, so it proved the endpoints worked rather than that anyone + * could reach them. + * + * These tests go through the rendered controls for that reason. A test that + * called the hook directly would reproduce the original blind spot exactly. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' + +const useTiresMock = vi.fn() +const useCreateTireMock = vi.fn() +const useCreateAndMountTireMock = vi.fn() +const useRetireTireMock = vi.fn() +const useRotateTiresMock = vi.fn() +const useDeleteTireMock = vi.fn() +const useTireSetsMock = vi.fn() +const useCreateTireSetMock = vi.fn() +const useMountTireSetMock = vi.fn() +const useUpdateTireMock = vi.fn() +const noop = () => ({ mutate: vi.fn(), isPending: false }) + +// Every mutation gets its OWN mock here, unlike TireList.test.tsx which aliases +// several onto one. The distinction is the whole subject: retire must not be +// delete, and create-into-storage must not be create-and-mount. +vi.mock('../../hooks/queries/useTires', () => ({ + useTires: () => useTiresMock(), + useCreateTire: () => useCreateTireMock(), + useCreateAndMountTire: () => useCreateAndMountTireMock(), + useUpdateTire: () => useUpdateTireMock(), + useMountTire: () => noop(), + useDismountTire: () => noop(), + useRetireTire: () => useRetireTireMock(), + useRotateTires: () => useRotateTiresMock(), + useAddTireReading: () => noop(), + useDeleteTire: () => useDeleteTireMock(), + useTireSets: () => useTireSetsMock(), + useCreateTireSet: () => useCreateTireSetMock(), + useUpdateTireSet: () => noop(), + useDeleteTireSet: () => noop(), + useMountTireSet: () => useMountTireSetMock(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})) + +// Metric, so the odometer fields submit what is typed and the assertions below +// are about the moves rather than about a conversion. Conversion has its own +// coverage in TireList.metric.test.tsx. +vi.mock('../../hooks/useUnitPreference', () => ({ + useUnitPreference: () => ({ + system: 'metric', + showBoth: false, + gallonStandard: 'us', + // METRIC_PRESET exactly as `/api/settings/public` serves it. `volume` is + // 'L', capital, and a lowercase 'l' resolves to no adapter at all -- the + // component then crashes on `adapter.unit` before rendering anything. + units: { + consumption: 'l_100km', + distance: 'km', + length: 'm', + mass: 'kg', + pressure: 'kpa', + secondary_gallon: 'us', + speed: 'kmh', + temperature: 'c', + torque: 'nm', + tread: 'mm', + volume: 'L', + }, + }), +})) + +import TireList from '../TireList' + +const VIN = '1HGCM82633A004352' +const CORNERS = ['FL', 'FR', 'RL', 'RR'] as const + +/** A mounted tire whose id encodes its corner, so a move is readable. */ +const tireAt = (id: number, position: string) => ({ + id, + vin: VIN, + position, + brand: 'Michelin', + model_name: null, + size: null, + dot_code: null, + tread_depth_mm: '7.50', + pressure_kpa: '240.00', + min_tread_mm: '3.00', + notes: null, + below_threshold: false, + projected_km_remaining: null, + projected_wear_date: null, + readings: [], +}) + +const FOUR_MOUNTED = [tireAt(1, 'FL'), tireAt(2, 'FR'), tireAt(3, 'RL'), tireAt(4, 'RR')] +/* SPARE counts. Four mounted tires leave the spare slot free, so Add still had + * somewhere to go and the storage default was never reached -- the first + * version of the tests below asserted against this list and passed on the old + * code too. */ +const EVERY_SLOT_TAKEN = [...FOUR_MOUNTED, tireAt(5, 'SPARE')] +const CORNER_BY_ID: Record = { 1: 'FL', 2: 'FR', 3: 'RL', 4: 'RR' } + +/** The four pattern chips, in the order the drawer renders them. */ +const PATTERN_KEYS = [ + 'tireList.rotatePatterns.forwardCross', + 'tireList.rotatePatterns.rearwardCross', + 'tireList.rotatePatterns.xPattern', + 'tireList.rotatePatterns.frontToBack', +] as const + +/** The open drawer. Both Rotate buttons render the same label, so scope. */ +const drawer = () => within(screen.getByRole('dialog')) + +const setSets = (sets: unknown[]) => + useTireSetsMock.mockReturnValue({ + data: { sets, total: sets.length }, + isLoading: false, + error: null, + }) + +const setTires = (tires: unknown[]) => + useTiresMock.mockReturnValue({ + data: { tires, total: tires.length }, + isLoading: false, + error: null, + }) + +describe('TireList rotation', () => { + afterEach(() => vi.restoreAllMocks()) + + beforeEach(() => { + useCreateTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useCreateAndMountTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useRetireTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useRotateTiresMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useDeleteTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useCreateTireSetMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useMountTireSetMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useUpdateTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + setSets([]) + setTires(FOUR_MOUNTED) + }) + + it.each(PATTERN_KEYS)('%s moves every tire to a distinct corner', (patternKey) => { + const mutate = vi.fn() + useRotateTiresMock.mockReturnValue({ mutate, isPending: false }) + + render() + fireEvent.click(screen.getByText('tireList.rotate')) + fireEvent.click(drawer().getByText(patternKey)) + fireEvent.click(drawer().getByText('tireList.rotate')) + + const moves = mutate.mock.calls[0][0].moves as { tire_id: number; position: string }[] + + // A permutation, asserted in both directions. A pattern that sent two + // tires to one corner would be rejected by the request schema with a 422 + // the user cannot act on; one that dropped a corner would silently leave a + // tire where it was, which reads as a rotation that worked. + expect(moves).toHaveLength(CORNERS.length) + expect([...moves.map((m) => m.tire_id)].sort()).toEqual([1, 2, 3, 4]) + expect([...moves.map((m) => m.position)].sort()).toEqual([...CORNERS].sort()) + + // And no tire stays put. True of all four standard patterns, and a + // rotation that leaves a tire on its own corner is not one. + for (const move of moves) { + expect(move.position).not.toBe(CORNER_BY_ID[move.tire_id]) + } + }) + + it('sends the pattern the user picked, not the default', () => { + const mutate = vi.fn() + useRotateTiresMock.mockReturnValue({ mutate, isPending: false }) + + render() + fireEvent.click(screen.getByText('tireList.rotate')) + fireEvent.click(drawer().getByText('tireList.rotatePatterns.frontToBack')) + fireEvent.change(drawer().getByLabelText('tireList.odometerWithUnit'), { + target: { value: '48000' }, + }) + fireEvent.click(drawer().getByText('tireList.rotate')) + + // Front-to-back keeps each tire on its own SIDE, which is the whole point + // of the pattern and the thing that distinguishes it from the other three. + // Asserted as the exact map rather than as a property, because "every tire + // stayed on its side" is also true of doing nothing. + expect(mutate.mock.calls[0][0]).toMatchObject({ + odometer_km: 48000, + moves: [ + { tire_id: 1, position: 'RL' }, + { tire_id: 2, position: 'RR' }, + { tire_id: 3, position: 'FL' }, + { tire_id: 4, position: 'FR' }, + ], + }) + }) + + it('is refused when a corner is empty', () => { + // Three mounted, one bare. The server would answer 404 for the missing + // tire or 409 for a corner held outside the rotation, and neither is + // something a user can act on from a list that already shows the gap. + setTires(FOUR_MOUNTED.slice(0, 3)) + render() + + expect(screen.getByText('tireList.rotate').closest('button')).toBeDisabled() + }) +}) + +describe('TireList retire', () => { + afterEach(() => vi.restoreAllMocks()) + + beforeEach(() => { + useCreateTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useCreateAndMountTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useRetireTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useRotateTiresMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useDeleteTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useCreateTireSetMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useMountTireSetMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useUpdateTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + setSets([]) + setTires(FOUR_MOUNTED) + }) + + it('retires the tire, and does not delete it', () => { + const retire = vi.fn() + const remove = vi.fn() + useRetireTireMock.mockReturnValue({ mutate: retire, isPending: false }) + useDeleteTireMock.mockReturnValue({ mutate: remove, isPending: false }) + + render() + fireEvent.click(screen.getAllByText('tireList.retire')[0]) + fireEvent.change(drawer().getByLabelText('tireList.odometerWithUnit'), { + target: { value: '61000' }, + }) + fireEvent.click(drawer().getByText('tireList.retire')) + + expect(retire).toHaveBeenCalledWith( + { tireId: 1, dismounted_odometer_km: 61000 }, + expect.anything() + ) + // The distinction the whole endpoint exists for: delete cascades through + // every reading and every mount period. + expect(remove).not.toHaveBeenCalled() + }) + + it('reaches a stored tire too', () => { + // A set can wear out and be replaced without ever going back on. + setTires([{ ...tireAt(9, 'FL'), position: null }]) + const retire = vi.fn() + useRetireTireMock.mockReturnValue({ mutate: retire, isPending: false }) + + render() + fireEvent.click(screen.getByText('tireList.retire')) + fireEvent.click(drawer().getByText('tireList.retire')) + + expect(retire.mock.calls[0][0].tireId).toBe(9) + }) +}) + +describe('TireList create into storage', () => { + afterEach(() => vi.restoreAllMocks()) + + beforeEach(() => { + useCreateTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useCreateAndMountTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useRetireTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useRotateTiresMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useDeleteTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useCreateTireSetMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useMountTireSetMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useUpdateTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + setSets([]) + setTires(FOUR_MOUNTED) + }) + + it('can still add a tire when every slot is taken', () => { + // The state a second seasonal set is entered in, and the one where Add was + // disabled outright -- so this was the single moment a winter set most + // obviously needed entering and nothing could be entered at all. + setTires(EVERY_SLOT_TAKEN) + render() + expect(screen.getByText('tireList.add').closest('button')).not.toBeDisabled() + }) + + it('posts to create, not create-and-mount, and carries no position', () => { + const create = vi.fn() + const createAndMount = vi.fn() + useCreateTireMock.mockReturnValue({ mutate: create, isPending: false }) + useCreateAndMountTireMock.mockReturnValue({ mutate: createAndMount, isPending: false }) + + render() + fireEvent.click(screen.getByText('tireList.add')) + fireEvent.click(drawer().getByText('tireList.inStorage')) + fireEvent.change(drawer().getByLabelText('tireList.brand'), { + target: { value: 'Nokian' }, + }) + fireEvent.click(drawer().getByText('common:save')) + + // `POST /tires` declares extra="forbid", so a `position` key on this + // payload is a 422 rather than a field the server ignores. + expect(createAndMount).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledTimes(1) + const payload = create.mock.calls[0][0] + expect(payload).toMatchObject({ vin: VIN, brand: 'Nokian' }) + expect(payload).not.toHaveProperty('position') + }) + + it('opens on storage when there is nowhere left to mount', () => { + const create = vi.fn() + const createAndMount = vi.fn() + useCreateTireMock.mockReturnValue({ mutate: create, isPending: false }) + useCreateAndMountTireMock.mockReturnValue({ mutate: createAndMount, isPending: false }) + + // No chip clicked: the default alone has to be storage, because the old + // fallback selected an OCCUPIED corner that the form then could not submit. + setTires(EVERY_SLOT_TAKEN) + render() + fireEvent.click(screen.getByText('tireList.add')) + fireEvent.click(drawer().getByText('common:save')) + + expect(createAndMount).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledTimes(1) + }) + + it('still mounts when a corner is free and chosen', () => { + // The pair to the test above: storage must not become the default for + // everyone, only the default when there is nowhere to mount. + setTires(FOUR_MOUNTED.slice(0, 3)) + const create = vi.fn() + const createAndMount = vi.fn() + useCreateTireMock.mockReturnValue({ mutate: create, isPending: false }) + useCreateAndMountTireMock.mockReturnValue({ mutate: createAndMount, isPending: false }) + + render() + fireEvent.click(screen.getByText('tireList.add')) + fireEvent.click(drawer().getByText('common:save')) + + expect(create).not.toHaveBeenCalled() + expect(createAndMount.mock.calls[0][0].position).toBe('RR') + }) +}) + +describe('TireList sets', () => { + const WINTER = { id: 7, vin: VIN, name: 'Winter studded', notes: null, created_at: '2026-01-01T00:00:00', tire_ids: [1, 2], mounted_count: 0 } + const EMPTY_SET = { id: 8, vin: VIN, name: 'Spares', notes: null, created_at: '2026-01-01T00:00:00', tire_ids: [], mounted_count: 0 } + + afterEach(() => vi.restoreAllMocks()) + + beforeEach(() => { + useCreateTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useCreateAndMountTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useRetireTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useRotateTiresMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useDeleteTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useCreateTireSetMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useMountTireSetMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useUpdateTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + setSets([]) + setTires(FOUR_MOUNTED) + }) + + it('creates a set from the name the user typed', () => { + const mutate = vi.fn() + useCreateTireSetMock.mockReturnValue({ mutate, isPending: false }) + + render() + fireEvent.click(screen.getByText('tireList.sets')) + fireEvent.change(drawer().getByLabelText('tireList.setName'), { + target: { value: ' Winter studded ' }, + }) + fireEvent.click(drawer().getByText('tireList.setAdd')) + + // Trimmed: a trailing space is invisible in the list and makes two sets + // that read identically. + expect(mutate).toHaveBeenCalledWith({ name: 'Winter studded' }, expect.anything()) + }) + + it('fits a set with the odometer, and sends no positions', () => { + const mutate = vi.fn() + useMountTireSetMock.mockReturnValue({ mutate, isPending: false }) + setSets([WINTER]) + + render() + fireEvent.click(screen.getByText('tireList.sets')) + fireEvent.click(drawer().getByText('tireList.setFit')) + fireEvent.change(drawer().getByLabelText('tireList.odometerWithUnit'), { + target: { value: '52000' }, + }) + // The expanded form's confirm, not the row control that opened it. + fireEvent.click(drawer().getAllByText('tireList.setFit')[1]) + + // No `moves`, no positions: the server reads each tire's own history for + // the corner it was last on. A client that guessed would have to reproduce + // that lookup and could disagree with it. + expect(mutate).toHaveBeenCalledWith( + { setId: 7, odometer_km: 52000 }, + expect.anything() + ) + }) + + it('does not offer to fit an empty set', () => { + setSets([EMPTY_SET]) + render() + fireEvent.click(screen.getByText('tireList.sets')) + + // The server answers 409 for this, and the message tells the user to go + // and put tires in the set -- which they cannot do from here. + expect(drawer().getByText('tireList.setFit').closest('button')).toBeDisabled() + }) + + it('files a tire into a set through the edit drawer', () => { + const mutate = vi.fn() + useUpdateTireMock.mockReturnValue({ mutate, isPending: false }) + setSets([WINTER]) + + render() + fireEvent.click(screen.getAllByLabelText('tireList.edit')[0]) + fireEvent.click(drawer().getByText('Winter studded')) + fireEvent.click(drawer().getByText('common:save')) + + expect(mutate.mock.calls[0][0]).toMatchObject({ tireId: 1, set_id: 7 }) + }) + + it('does not offer the set picker while adding', () => { + // `POST /tires` declares extra="forbid", so a `set_id` on a create is a + // 422. A control that cannot be used yet reads as broken. + setSets([WINTER]) + render() + fireEvent.click(screen.getByText('tireList.add')) + + expect(drawer().queryByText('tireList.setLabel')).toBeNull() + }) + + it('groups stored tires under their set name', () => { + setSets([WINTER]) + setTires([ + { ...tireAt(1, 'FL'), position: null, set_id: 7, brand: 'Nokian' }, + { ...tireAt(2, 'FR'), position: null, set_id: null, brand: 'Loose' }, + ]) + + render() + + // Both headings, because two groups exist. One flat list is what makes a + // second seasonal set unreadable. + expect(screen.getByText('Winter studded')).toBeInTheDocument() + expect(screen.getByText('tireList.setUngrouped')).toBeInTheDocument() + }) + + it('does not label the group when there is only one', () => { + // A single-set owner sees exactly what they saw before sets existed. + setSets([WINTER]) + setTires([{ ...tireAt(1, 'FL'), position: null, set_id: 7, brand: 'Nokian' }]) + + render() + + expect(screen.queryByText('Winter studded')).toBeNull() + }) +}) diff --git a/frontend/src/components/__tests__/TireList.test.tsx b/frontend/src/components/__tests__/TireList.test.tsx index 77c0d2b9..01bd0d6c 100644 --- a/frontend/src/components/__tests__/TireList.test.tsx +++ b/frontend/src/components/__tests__/TireList.test.tsx @@ -7,11 +7,27 @@ const useUpsertTireMock = vi.fn() const useAddTireReadingMock = vi.fn() const useDeleteTireMock = vi.fn() +// v3.3.0 split the single upsert into create-and-mount plus update, and added +// mount/dismount. The old `useUpsertTire` name is kept for the mock variable +// because every assertion below is about the payload a save produces, and that +// payload is now create-and-mount's. vi.mock('../../hooks/queries/useTires', () => ({ useTires: () => useTiresMock(), - useUpsertTire: () => useUpsertTireMock(), + useCreateAndMountTire: () => useUpsertTireMock(), + useCreateTire: () => useUpsertTireMock(), + useUpdateTire: () => useUpsertTireMock(), + useMountTire: () => useUpsertTireMock(), + useDismountTire: () => useUpsertTireMock(), + useRetireTire: () => useUpsertTireMock(), + useRotateTires: () => useUpsertTireMock(), useAddTireReading: () => useAddTireReadingMock(), useDeleteTire: () => useDeleteTireMock(), + // Sets are not this file's subject; it just has to render past them. + useTireSets: () => ({ data: { sets: [], total: 0 }, isLoading: false, error: null }), + useCreateTireSet: () => useUpsertTireMock(), + useUpdateTireSet: () => useUpsertTireMock(), + useDeleteTireSet: () => useUpsertTireMock(), + useMountTireSet: () => useUpsertTireMock(), })) vi.mock('@tanstack/react-query', () => ({ @@ -91,13 +107,19 @@ describe('TireList', () => { fireEvent.click(screen.getByLabelText('tireList.edit')) fireEvent.click(screen.getByText('common:save')) - expect(mutate.mock.calls[0][0]).toMatchObject({ - position: 'FL', + // No `position` in an edit payload since v3.3.0. Editing a tire's + // metadata does not move it: where a tire sits changes through + // mount/dismount, and `position` is not a writable field at all -- the + // backend rejects a payload carrying one with a 422. + const payload = mutate.mock.calls[0][0] + expect(payload).toMatchObject({ + tireId: 1, brand: 'Michelin', model_name: 'Pilot Sport 4', size: '225/45R17', dot_code: '2324', }) + expect(payload).not.toHaveProperty('position') }) it('posts a blank reading odometer as null, stepping the field in whole units', () => { @@ -538,3 +560,85 @@ describe('TireList', () => { }) }) }) + +describe('TireList – mount and dismount', () => { + const MOUNTED = { + id: 1, + vin: '1HGCM82633A004352', + position: 'FL', + brand: 'Michelin', + below_threshold: false, + readings: [], + mount_periods: [], + distance_status: 'nothing_bounded', + blocking_period_ids: [], + created_at: '2026-01-01T00:00:00', + } + const STORED = { ...MOUNTED, id: 2, position: null, brand: 'Winter' } + + beforeEach(() => { + vi.clearAllMocks() + useAddTireReadingMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useDeleteTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + }) + + it('lists a stored tire without rendering an undefined position', () => { + useTiresMock.mockReturnValue({ + data: { tires: [STORED], total: 1 }, + isLoading: false, + error: null, + }) + useUpsertTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + + render() + + // A tire off the vehicle still has to be identifiable. Before the label + // helper this rendered `positionLabels[null]`, i.e. nothing at all. + expect(screen.getAllByText('tireList.inStorage').length).toBeGreaterThan(0) + expect(screen.queryByText('undefined')).toBeNull() + }) + + it('does not carry a mount odometer over into the dismount drawer', () => { + // These were one piece of state. A value typed into Mount and abandoned + // reappeared in Dismount and silently became that period's closing bound + // -- the number a tire's whole distance is computed from. + useTiresMock.mockReturnValue({ + data: { tires: [MOUNTED, STORED], total: 2 }, + isLoading: false, + error: null, + }) + const mutate = vi.fn() + useUpsertTireMock.mockReturnValue({ mutate, isPending: false }) + + render() + + // Addressed by id: both drawers are in the DOM at once, so an accessible + // name shared between them matches two fields. + fireEvent.click(screen.getByText('tireList.mount')) + const mountField = document.querySelector('#mount-odometer') as HTMLInputElement + expect(mountField).toBeTruthy() + fireEvent.change(mountField, { target: { value: '54321' } }) + fireEvent.click(screen.getAllByText('common:cancel')[0]) + + fireEvent.click(screen.getByText('tireList.dismount')) + const dismountField = document.querySelector('#dismount-odometer') as HTMLInputElement + expect(dismountField).toBeTruthy() + expect(dismountField.value).toBe('') + }) + + it('renders a per-status distance message rather than a zero', () => { + // `nothing_bounded` is the state of EVERY tire immediately after + // upgrading, so rendering it as "0 km" would tell every user their tires + // had never been driven on. + useTiresMock.mockReturnValue({ + data: { tires: [STORED], total: 1 }, + isLoading: false, + error: null, + }) + useUpsertTireMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + + render() + + expect(screen.getByText('tireList.distance.nothingBounded')).toBeTruthy() + }) +}) diff --git a/frontend/src/components/livelink/NoMovementSignalNotice.tsx b/frontend/src/components/livelink/NoMovementSignalNotice.tsx new file mode 100644 index 00000000..d02e02f9 --- /dev/null +++ b/frontend/src/components/livelink/NoMovementSignalNotice.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from 'react-i18next' +import { AlertTriangle } from 'lucide-react' +import type { LiveLinkDevice } from '@/types/livelink' + +/** + * Names devices that report telemetry but nothing recognisable as movement. + * + * Drive sessions are decided by speed, an odometer increase, or RPM. A device + * whose speed arrives under a name this codebase does not know records **no + * drives at all** -- and "no drives" is indistinguishable from "the vehicle was + * parked" unless something says otherwise. A silent zero is precisely the + * failure the boundary rework exists to remove, so reintroducing one for this + * cohort would be absurd. + * + * The backend picks the cohort, because the answer needs the devices' parameter + * keys; see `LiveLinkService.movement_unreadable_device_ids` for why the browser + * cannot and what an earlier version of this file got wrong by trying. + * + * The backend logs the same conclusion once per process with the keys attached. + * This is the half that survives a log rotation and reaches someone who is not + * reading logs. + */ + +interface Props { + devices: LiveLinkDevice[] +} + +export default function NoMovementSignalNotice({ devices }: Props): React.ReactElement | null { + const { t } = useTranslation('forms') + + const affected = devices.filter((device) => device.movement_unreadable) + + if (affected.length === 0) return null + + return ( +
+ +
+

{t('modal.livelink.noMovementSignal')}

+

+ {t('modal.livelink.noMovementSignalDesc', { + devices: affected.map((device) => device.label || device.device_id).join(', '), + })} +

+
+
+ ) +} diff --git a/frontend/src/components/modals/LiveLinkSettingsModal.tsx b/frontend/src/components/modals/LiveLinkSettingsModal.tsx index f80d1e53..586e80a8 100644 --- a/frontend/src/components/modals/LiveLinkSettingsModal.tsx +++ b/frontend/src/components/modals/LiveLinkSettingsModal.tsx @@ -28,6 +28,7 @@ import { Download, } from 'lucide-react' import { livelinkService } from '@/services/livelinkService' +import NoMovementSignalNotice from '@/components/livelink/NoMovementSignalNotice' import { vehicleService } from '@/services/vehicleService' import { Select, Drawer, Toggle } from '@/components/ui' import type { @@ -754,6 +755,8 @@ export default function LiveLinkSettingsModal({ isOpen, onClose }: LiveLinkSetti
+ + {/* Section: Alerts & Notifications */}
@@ -820,7 +823,52 @@ export default function LiveLinkSettingsModal({ isOpen, onClose }: LiveLinkSetti {t('modal.livelink.sessionGracePeriodDesc')}

-
{/* Empty cell for grid alignment */} +
+ +
+ handleSaveSettings({ session_gap_minutes: parseInt(e.target.value) })} + disabled={saving} + className="w-20 px-3 py-2 bg-garage-surface border border-garage-border rounded-lg text-garage-text text-sm focus:ring-2 focus:ring-primary" + /> + {t('modal.livelink.minutes')} +
+

+ {t('modal.livelink.sessionGapDesc')} +

+
+
+ + setFormData({ ...formData, webhook_ingest_token: e.target.value })} - className="w-full px-3 py-2 bg-garage-bg border border-garage-border rounded-lg text-garage-text focus:outline-none focus:ring-2 focus:ring-primary font-mono text-sm" - placeholder={t('integrations.webhookTokenPlaceholder')} - autoComplete="off" - /> -

{t('integrations.webhookTokenDesc')}

-
-
-

- POST /api/v1/webhooks/fuel|odometer|reminders/complete -

-

{t('integrations.webhookHeaderHint')}

-
-
- - -
-
- -
-

{t('integrations.telegramInbound')}

-

{t('integrations.telegramInboundDesc')}

-
-
-
-
- - setFormData({ ...formData, telegram_inbound_enabled: next ? 'true' : 'false' }) - } - /> -

- {t('integrations.enableTelegramInboundDesc')} -

-
-
-

{t('integrations.telegramCommandHint')}

-

- fuel <vin|nickname> <odo>[km|mi] <vol>[L|gal|kWh] [price] [cost] -

-
-
-
- -
-
- -
-

{t('integrations.llmSection')}

-

{t('integrations.llmSectionDesc')}

-
-
+ {/* LLM is full width: its three credential fields want a row, and a + half-width column would stack them into a tower. */} +
- - setFormData({ ...formData, llm_garage_assistant_enabled: next ? 'true' : 'false' }) - } - /> -

- {t('integrations.enableLlmAssistantDesc')} -

+ {/* Wrapped like every other toggle-plus-description pair on this tab. + Left as a bare sibling of `space-y-4`, the description took the + container's 16px gap instead of its own 4px and had to be dragged + back up with a negative margin. */} +
+ + setFormData({ ...formData, llm_garage_assistant_enabled: next ? 'true' : 'false' }) + } + /> +

+ {t('integrations.enableLlmAssistantDesc')} +

+

{t('integrations.llmHint')}

-
-
- - {/* NHTSA (tall) on the left; CarComplaints + LiveLink stacked on the right. - items-start so the shorter right column doesn't stretch to NHTSA's height. */} -
- {/* NHTSA Integration */} -
-
- -
-

{t('integrations.nhtsa')}

-

- {t('integrations.nhtsaDesc')} -

-
-
+ + + {/* The remaining four (five with LiveLink) flow as a masonry rather than + sitting in a fixed 2-col grid. The grid paired a ~530px NHTSA card + against ~280px of stacked cards and left the rest of that row empty; + columns let the short ones close the gap themselves. Source order is + preserved, so the one-column mobile reading order still groups. */} +
+
{/* Enable NHTSA Integration */} @@ -436,7 +426,7 @@ export default function SettingsIntegrationsTab() { checked={formData.nhtsa_enabled === 'true'} onChange={(next) => setFormData({ ...formData, nhtsa_enabled: next ? 'true' : 'false' })} /> -

+

{t('integrations.enableNHTSADesc')}

@@ -449,7 +439,7 @@ export default function SettingsIntegrationsTab() { disabled={formData.nhtsa_enabled === 'false'} onChange={(next) => setFormData({ ...formData, nhtsa_auto_check: next ? 'true' : 'false' })} /> -

+

{t('integrations.enableAutoCheckDesc')}

@@ -511,29 +501,78 @@ export default function SettingsIntegrationsTab() {

- + - {/* Right column: CarComplaints + LiveLink stacked */} -
- {/* CarComplaints Integration */} -
-
- -
-

{t('integrations.carComplaints')}

-

- {t('integrations.carComplaintsDesc')} -

+ +
+
+ + setFormData({ ...formData, webhook_ingest_token: e.target.value })} + className="w-full px-3 py-2 bg-garage-bg border border-garage-border rounded-lg text-garage-text focus:outline-none focus:ring-2 focus:ring-primary font-mono text-sm" + placeholder={t('integrations.webhookTokenPlaceholder')} + autoComplete="off" + /> +

{t('integrations.webhookTokenDesc')}

+
+
+

+ POST /api/v1/webhooks/fuel|odometer|reminders/complete +

+

{t('integrations.webhookHeaderHint')}

+
- -
+ + + +
+
+ + setFormData({ ...formData, telegram_inbound_enabled: next ? 'true' : 'false' }) + } + /> +

+ {t('integrations.enableTelegramInboundDesc')} +

+
+
+

{t('integrations.telegramCommandHint')}

+

+ fuel <vin|nickname> <odo>[km|mi] <vol>[L|gal|kWh] [price] [cost] +

+
+
+
+ + setHelpDrawer('carcomplaints')} + /> + } + >
{/* Enable CarComplaints Integration */} @@ -543,34 +582,27 @@ export default function SettingsIntegrationsTab() { checked={formData.carcomplaints_enabled === 'true'} onChange={(next) => setFormData({ ...formData, carcomplaints_enabled: next ? 'true' : 'false' })} /> -

+

{t('integrations.enableCarComplaintsDesc')}

-
+ - {/* LiveLink Integration — admin-only (infra endpoints require admin, v2.28.0; - shown in none-mode where auth is disabled) */} {canManageLiveLink && ( -
-
- -
-

{t('integrations.livelink')}

-

- {t('integrations.livelinkDesc')} -

-
- -
+ /> + } + >
{livelinkLoading ? ( @@ -635,28 +667,23 @@ export default function SettingsIntegrationsTab() { )}
-
+ )} -
+ - {/* Shop Finder Integration — full width for the provider table */} -
-
- -
-

{t('integrations.shopFinder')}

-

- {t('integrations.shopFinderDesc')} -

-
-
+ {/* Shop Finder is full width for the provider table. */} +
- + @@ -670,32 +697,41 @@ export default function SettingsIntegrationsTab() { : provider.display_name} - ))} @@ -710,8 +746,7 @@ export default function SettingsIntegrationsTab() { {t('integrations.addService')} - - + {/* Modals — rendered at the tab root, outside the grid */} = {}) => - ({ sessions: [endedSession], total: 1, ...over }) satisfies DriveSessionListResponse + ({ sessions: [endedSession], total: 1, stationary_total: 0, ...over }) satisfies DriveSessionListResponse beforeEach(() => { vi.clearAllMocks() @@ -97,7 +100,72 @@ describe('LiveLinkSessionsTab', () => { // reader's own unit can be claimed for it again. expect(screen.getByText('62 mi')).toBeInTheDocument() expect(screen.getByText('37 mph')).toBeInTheDocument() // 60 km/h / 1.60934, at 0 dp - expect(getSessions.mock.calls).toStrictEqual([['V1', { limit: 50 }]]) // M1: exact call identity + expect(getSessions.mock.calls).toStrictEqual([['V1', { limit: 50, include_stationary: false }]]) + }) + + // ─────────────────────────────────────────────────────────────────────────── + // Hiding drives the old rule invented. + // + // Sessions used to open whenever the dongle reached the broker, and a parked + // WiCAN checks in about every 95 minutes. On the instance this was built + // against, 2,921 of 3,262 recorded sessions never moved at all. They cannot + // be rebuilt (the telemetry was never captured) and must not be deleted (a + // release that tried removed 2,700 km of real distance), so the list hides + // them by default and says so. + // + // The filter is MOVEMENT, not `boundary_algorithm_version`: 341 of that same + // history are pre-v3.3.0 sessions in which the vehicle really did move. + // ─────────────────────────────────────────────────────────────────────────── + + it('asks the API to leave out sessions in which nothing moved, by default', async () => { + render() + await screen.findByText('1h 0m') + expect(getSessions).toHaveBeenCalledWith('V1', { limit: 50, include_stationary: false }) + }) + + it('explains an empty list rather than looking broken when drives are hidden', async () => { + // The upgrade case: every drive on record predates movement detection, so + // the default view is legitimately empty. An unexplained blank page here is + // indistinguishable from a failure, and this is the state every existing + // instance lands in on the day it upgrades. + getSessions.mockResolvedValue(list({ sessions: [], total: 0, stationary_total: 2921 })) + render() + + expect(await screen.findByText('livelink.sessions.stationaryHiddenTitle')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'livelink.sessions.showStationary' })).toBeInTheDocument() + }) + + it('keeps the ordinary empty state when there is nothing hidden either', async () => { + // A genuinely new instance has no drives and nothing to explain. Offering + // to reveal 0 hidden drives would be nonsense. + getSessions.mockResolvedValue(list({ sessions: [], total: 0, stationary_total: 0 })) + render() + + expect(await screen.findByText('livelink.sessions.noRecords')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'livelink.sessions.showStationary' })).toBeNull() + }) + + it('refetches with the old drives included when asked, and can put them back', async () => { + getSessions.mockResolvedValue(list({ sessions: [], total: 0, stationary_total: 2921 })) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'livelink.sessions.showStationary' })) + + await vi.waitFor(() => + expect(getSessions).toHaveBeenLastCalledWith('V1', { limit: 50, include_stationary: true }) + ) + }) + + it('marks a drive the old rule recorded, so a revealed row says what it is', async () => { + const legacyRow = { ...endedSession, id: 9, boundary_algorithm_version: 0 } + const modernRow = { ...endedSession, id: 10, boundary_algorithm_version: 1 } + getSessions.mockResolvedValue( + list({ sessions: [legacyRow, modernRow], total: 2, stationary_total: 1 }) + ) + render() + + await screen.findAllByText('1h 0m') + expect(screen.getAllByText('livelink.sessions.legacyBadge')).toHaveLength(1) }) it('shows the in-progress chip only for an active (unended) session — both ways (fails if the isActive marker is dropped or shown unconditionally)', async () => { diff --git a/frontend/src/components/tabs/__tests__/SettingsIntegrationsTab.test.tsx b/frontend/src/components/tabs/__tests__/SettingsIntegrationsTab.test.tsx new file mode 100644 index 00000000..58102bfb --- /dev/null +++ b/frontend/src/components/tabs/__tests__/SettingsIntegrationsTab.test.tsx @@ -0,0 +1,155 @@ +/** + * Structural cover for the Integrations tab. + * + * Written BEFORE converting the tab's seven hand-rolled + * `bg-garage-surface rounded-lg border ... p-6` blocks onto `Card` / + * `CardHeader`, because the file had no test at all: a mechanical refactor of + * 771 lines with nothing asserting that every section still renders is how a + * card quietly disappears behind a mis-paired ``. + * + * So this asserts the inventory (every section is present, and the two that + * carry an About sidecar still offer it), plus the provider table's state + * column, which is a real accessibility fix rather than a cosmetic one: it + * rendered a bare lucide Check / X with no accessible name, so a screen reader + * announced an empty cell for every provider. + */ + +import { useEffect } from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { SettingsProvider, useSettings } from '@/contexts/SettingsContext' + +vi.mock('@/services/api', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, +})) + +// Same reason as SettingsSystemTab.test.tsx: the global setup mock hands back a +// fresh `t` per call, which re-fires the load effects forever. Pin one. +vi.mock('react-i18next', () => { + const stableT = (key: string) => key + return { + useTranslation: () => ({ + t: stableT, + i18n: { language: 'en', changeLanguage: () => Promise.resolve() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, + } +}) + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => ({ isAuthenticated: true, isAdmin: true, authMode: 'local', user: {} }), +})) + +vi.mock('@/services/livelinkService', () => ({ + livelinkService: { + getSettings: vi.fn().mockResolvedValue({ enabled: false }), + getDevices: vi.fn().mockResolvedValue({ total: 3, online_count: 0, devices: [] }), + getDeviceFirmwareStatus: vi.fn().mockResolvedValue([]), + }, +})) + +// Children that fetch on their own; not under test here. +vi.mock('../../settings/WidgetKeysPanel', () => ({ default: () =>
})) +vi.mock('../../modals/AddProviderModal', () => ({ default: () => null })) +vi.mock('../../modals/EditProviderModal', () => ({ default: () => null })) +vi.mock('../../modals/LiveLinkSettingsModal', () => ({ default: () => null })) + +import api from '@/services/api' +import SettingsIntegrationsTab from '../SettingsIntegrationsTab' + +const mockedApi = vi.mocked(api) + +const PROVIDERS = [ + { + name: 'tomtom', + display_name: 'TomTom Places API', + enabled: true, + is_default: false, + api_usage: 0, + api_limit: 2500, + priority: 1, + }, + { + name: 'google', + display_name: 'Google Places', + enabled: false, + is_default: false, + api_usage: 0, + api_limit: null, + priority: 2, + }, +] + +function ActiveIntegrationsTab() { + const { setCurrentTabId } = useSettings() + useEffect(() => { + setCurrentTabId('integrations') + }, [setCurrentTabId]) + return +} + +function renderTab(): void { + render( + + + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockedApi.get.mockImplementation((url: string) => { + if (url === '/settings/poi-providers') { + return Promise.resolve({ data: { providers: PROVIDERS } }) + } + return Promise.resolve({ data: { settings: [] } }) + }) +}) + +describe('SettingsIntegrationsTab', () => { + it('renders every integration section', async () => { + renderTab() + + // One assertion per card. If a refactor drops or nests one wrongly, the + // specific name says which. + for (const key of [ + 'integrations.webhooks', + 'integrations.telegramInbound', + 'integrations.llmSection', + 'integrations.nhtsa', + 'integrations.carComplaints', + 'integrations.livelink', + 'integrations.shopFinder', + ]) { + expect(await screen.findByText(key), key).toBeInTheDocument() + } + + // The API keys panel is a separate component, mounted at the top. + expect(screen.getByTestId('widget-keys')).toBeInTheDocument() + }) + + it('keeps the About sidecar trigger on the two cards that document themselves', async () => { + renderTab() + + expect( + await screen.findByRole('button', { name: 'integrations.aboutCarComplaints' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'integrations.aboutLiveLink' }), + ).toBeInTheDocument() + }) + + it('names the enabled state of each provider in text, not only as an icon', async () => { + renderTab() + + await waitFor(() => { + expect(screen.getByText('TomTom Places API')).toBeInTheDocument() + }) + + // Both rows must carry a readable state. The retired Check / X icons had no + // accessible name, so this assertion is false against that version. + expect(screen.getByText('integrations.statusActive')).toBeInTheDocument() + expect(screen.getByText('integrations.statusInactive')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/vehicle-detail/VehicleSpecsPanel.tsx b/frontend/src/components/vehicle-detail/VehicleSpecsPanel.tsx index 04da74d3..d3579c3c 100644 --- a/frontend/src/components/vehicle-detail/VehicleSpecsPanel.tsx +++ b/frontend/src/components/vehicle-detail/VehicleSpecsPanel.tsx @@ -16,7 +16,7 @@ import { type UnitFieldOrigin, type UnitFormat, } from '../../utils/unitFormat' -import { useUnitFormat } from '../../hooks/useUnitFormat' +import { useUnitFormat, useOilCapacityFormat } from '../../hooks/useUnitFormat' import type { Vehicle, VehicleUpdate } from '../../types/vehicle' interface VehicleSpecsPanelProps { @@ -42,6 +42,7 @@ type SpecForm = { oil_viscosity: string oil_capacity: string oil_filter_part_number: string + fuel_filter_part_number: string lug_nut_torque: string coolant_type: string brake_fluid_type: string @@ -77,10 +78,10 @@ function readUnitField( return { ok: true, value: quantity.toCanonical(parsed.value) } } -function seedForm(vehicle: Vehicle, u: UnitFormat): SpecForm { +function seedForm(vehicle: Vehicle, u: UnitFormat, oilFormat: QuantityFormat): SpecForm { const oil = seedUnitField( vehicle.oil_capacity_liters != null ? Number(vehicle.oil_capacity_liters) : null, - u.volume + oilFormat ) const torque = seedUnitField( vehicle.lug_nut_torque_nm != null ? Number(vehicle.lug_nut_torque_nm) : null, @@ -91,6 +92,7 @@ function seedForm(vehicle: Vehicle, u: UnitFormat): SpecForm { oil_viscosity: str(vehicle.oil_viscosity), oil_capacity: oil.display, oil_filter_part_number: str(vehicle.oil_filter_part_number), + fuel_filter_part_number: str(vehicle.fuel_filter_part_number), lug_nut_torque: torque.display, coolant_type: str(vehicle.coolant_type), brake_fluid_type: str(vehicle.brake_fluid_type), @@ -117,13 +119,16 @@ export default function VehicleSpecsPanel({ }: VehicleSpecsPanelProps) { const { t } = useTranslation('vehicles') const u = useUnitFormat() + // Oil capacity is quarts where fuel is gallons; `u.volume` is the fuel + // adapter and asking it for oil stored 3.785x over. See utils/oilCapacityUnit.ts. + const oilFormat = useOilCapacityFormat() const [open, setOpen] = useState(false) const [saving, setSaving] = useState(false) - const [form, setForm] = useState(() => seedForm(vehicle, u)) + const [form, setForm] = useState(() => seedForm(vehicle, u, oilFormat)) useEffect(() => { - if (open) setForm(seedForm(vehicle, u)) - }, [open, vehicle, u]) + if (open) setForm(seedForm(vehicle, u, oilFormat)) + }, [open, vehicle, u, oilFormat]) useEffect(() => { if (editRequestKey > 0) setOpen(true) @@ -133,6 +138,7 @@ export default function VehicleSpecsPanel({ vehicle.oil_viscosity || vehicle.oil_capacity_liters != null || vehicle.oil_filter_part_number || + vehicle.fuel_filter_part_number || vehicle.lug_nut_torque_nm != null || vehicle.coolant_type || vehicle.brake_fluid_type || @@ -153,7 +159,7 @@ export default function VehicleSpecsPanel({ const oil = readUnitField( form.oil_capacity, form.origins.oil_capacity_liters, - u.volume, + oilFormat, locale ) if (!oil.ok) { @@ -178,6 +184,7 @@ export default function VehicleSpecsPanel({ oil_viscosity: emptyToNull(form.oil_viscosity), oil_capacity_liters: oil.value, oil_filter_part_number: emptyToNull(form.oil_filter_part_number), + fuel_filter_part_number: emptyToNull(form.fuel_filter_part_number), lug_nut_torque_nm: torque.value, coolant_type: emptyToNull(form.coolant_type), brake_fluid_type: emptyToNull(form.brake_fluid_type), @@ -198,7 +205,7 @@ export default function VehicleSpecsPanel({ /* One interpolated key per field, carrying the resolved unit, replacing the * pair of hardcoded 'gal'/'L' strings and the binary getTorqueUnit call. */ - const oilCapacityLabel = t('detail.specs.oilCapacityWithUnit', { unit: u.volume.label }) + const oilCapacityLabel = t('detail.specs.oilCapacityWithUnit', { unit: oilFormat.label }) const lugTorqueLabel = t('detail.specs.lugTorqueWithUnit', { unit: u.torque.label }) return ( @@ -210,7 +217,7 @@ export default function VehicleSpecsPanel({ /> {hasSpecs ? ( -
+
{vehicle.oil_viscosity && (

{t('detail.specs.oilViscosity')}

@@ -221,7 +228,7 @@ export default function VehicleSpecsPanel({

{t('detail.specs.oilCapacity')}

- {u.volume.format(Number(vehicle.oil_capacity_liters))} + {oilFormat.format(Number(vehicle.oil_capacity_liters))}
)} @@ -231,6 +238,12 @@ export default function VehicleSpecsPanel({ {vehicle.oil_filter_part_number}
)} + {vehicle.fuel_filter_part_number && ( +
+

{t('detail.specs.fuelFilter')}

+ {vehicle.fuel_filter_part_number} +
+ )} {vehicle.lug_nut_torque_nm != null && (

{t('detail.specs.lugTorque')}

@@ -286,7 +299,7 @@ export default function VehicleSpecsPanel({
} > -
+
setField('oil_filter_part_number', e.target.value)} /> + + setField('fuel_filter_part_number', e.target.value)} + /> + setField('transmission_fluid_type', e.target.value)} /> - -
{t('integrations.provider')}{t('integrations.active')}{t('integrations.status')} {t('integrations.apiLimits')} {t('integrations.options')}
- {provider.enabled ? ( - - ) : ( - - )} + {/* Was a bare lucide Check / X with no accessible name, so a + screen reader announced an empty cell for every provider, + and five red X glyphs read as five errors rather than as + five switched-off providers. */} + + {provider.enabled + ? t('integrations.statusActive') + : t('integrations.statusInactive')} + {provider.api_limit ? `${provider.api_usage}/${provider.api_limit}` : `${provider.api_usage || 0}/${t('integrationsTab.unlimited')}`} - - {!provider.is_default && ( - - )} + + {/* Icon buttons rather than two text links: a red "Remove" on + every row made a routine table look destructive. The label + is what a screen reader reads, so nothing is lost. */} +
+ handleEditProvider(provider)} + /> + {!provider.is_default && ( + handleRemoveProvider(provider.name)} + /> + )} +