|
| 1 | +"""Checks ``update_config`` against the shapes the config API really returns. |
| 2 | +
|
| 3 | +Success is reported flat as ``{"updated_vehicles": n}``, optionally with an |
| 4 | +``ignoredFields`` list naming fields the API dropped, and errors arrive as |
| 5 | +``{"response": null, "error": ...}``. |
| 6 | +""" |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import asyncio |
| 10 | +import logging |
| 11 | +from typing import Any |
| 12 | + |
| 13 | +from teslemetry_stream.vehicle import TeslemetryStreamVehicle |
| 14 | + |
| 15 | +VIN_A = "TESTVIN0000000001" |
| 16 | +VIN_B = "TESTVIN0000000002" |
| 17 | + |
| 18 | +# Response bodies as observed against the live API |
| 19 | +ACCEPTED: dict[str, Any] = {"updated_vehicles": 1} |
| 20 | +IGNORED: dict[str, Any] = {"updated_vehicles": 1, "ignoredFields": ["RouteLastUpdated"]} |
| 21 | +ALL_IGNORED: dict[str, Any] = { |
| 22 | + "updated_vehicles": 0, |
| 23 | + "ignoredFields": ["LifetimeEnergyUsedDrive", "RouteLastUpdated"], |
| 24 | +} |
| 25 | +FAILED: dict[str, Any] = {"response": None, "error": "upstream internal error"} |
| 26 | + |
| 27 | + |
| 28 | +class FakeStream: |
| 29 | + """Minimal stand-in for TeslemetryStream.""" |
| 30 | + |
| 31 | + manual = True |
| 32 | + |
| 33 | + |
| 34 | +def make_vehicle(vin: str, responses: list[Any]) -> TeslemetryStreamVehicle: |
| 35 | + """Build a vehicle that records payloads and replays canned responses.""" |
| 36 | + vehicle = TeslemetryStreamVehicle(FakeStream(), vin) # type: ignore[arg-type] |
| 37 | + vehicle.sent = [] # type: ignore[attr-defined] |
| 38 | + |
| 39 | + async def patch_config(config: dict[str, Any]) -> dict[str, Any]: |
| 40 | + vehicle.sent.append(dict(config)) # type: ignore[attr-defined] |
| 41 | + return responses.pop(0) if responses else ACCEPTED |
| 42 | + |
| 43 | + vehicle.patch_config = patch_config # type: ignore[assignment,method-assign] |
| 44 | + return vehicle |
| 45 | + |
| 46 | + |
| 47 | +class CaptureWarnings(logging.Handler): |
| 48 | + """Collect formatted WARNING records emitted by the library.""" |
| 49 | + |
| 50 | + def __init__(self) -> None: |
| 51 | + super().__init__(level=logging.WARNING) |
| 52 | + self.messages: list[str] = [] |
| 53 | + |
| 54 | + def emit(self, record: logging.LogRecord) -> None: |
| 55 | + self.messages.append(record.getMessage()) |
| 56 | + |
| 57 | + |
| 58 | +def check(label: str, ok: bool, detail: str = "") -> bool: |
| 59 | + print(f"{label:<56} {'PASS' if ok else 'FAIL'}{' ' + detail if detail else ''}") |
| 60 | + return ok |
| 61 | + |
| 62 | + |
| 63 | +async def main() -> None: |
| 64 | + results = [] |
| 65 | + |
| 66 | + # A confirmed update records its fields and leaves nothing pending. |
| 67 | + vehicle = make_vehicle(VIN_A, [ACCEPTED]) |
| 68 | + await vehicle.update_config({"fields": {"BatteryLevel": {"interval_seconds": 60}}}) |
| 69 | + results.append( |
| 70 | + check( |
| 71 | + "a confirmed update records its fields", |
| 72 | + "BatteryLevel" in vehicle.fields and vehicle._config == {}, |
| 73 | + f"fields {sorted(vehicle.fields)}, pending {vehicle._config}", |
| 74 | + ) |
| 75 | + ) |
| 76 | + |
| 77 | + # Ignored fields are named in a warning. |
| 78 | + handler = CaptureWarnings() |
| 79 | + logger = logging.getLogger("teslemetry_stream") |
| 80 | + logger.addHandler(handler) |
| 81 | + try: |
| 82 | + vehicle = make_vehicle(VIN_A, [IGNORED]) |
| 83 | + await vehicle.update_config( |
| 84 | + {"fields": {"BatteryLevel": None, "RouteLastUpdated": None}} |
| 85 | + ) |
| 86 | + results.append( |
| 87 | + check( |
| 88 | + "ignored fields are named in a warning", |
| 89 | + any("RouteLastUpdated" in m for m in handler.messages), |
| 90 | + f"warnings {handler.messages}", |
| 91 | + ) |
| 92 | + ) |
| 93 | + |
| 94 | + handler.messages.clear() |
| 95 | + vehicle = make_vehicle(VIN_A, [ALL_IGNORED]) |
| 96 | + await vehicle.update_config({"fields": {"RouteLastUpdated": None}}) |
| 97 | + results.append( |
| 98 | + check( |
| 99 | + "every ignored field is named", |
| 100 | + any( |
| 101 | + "RouteLastUpdated" in m and "LifetimeEnergyUsedDrive" in m |
| 102 | + for m in handler.messages |
| 103 | + ), |
| 104 | + f"warnings {handler.messages}", |
| 105 | + ) |
| 106 | + ) |
| 107 | + |
| 108 | + handler.messages.clear() |
| 109 | + vehicle = make_vehicle(VIN_A, [ACCEPTED]) |
| 110 | + await vehicle.update_config({"fields": {"BatteryLevel": None}}) |
| 111 | + results.append( |
| 112 | + check( |
| 113 | + "no warning when nothing was ignored", |
| 114 | + not handler.messages, |
| 115 | + f"warnings {handler.messages}", |
| 116 | + ) |
| 117 | + ) |
| 118 | + finally: |
| 119 | + logger.removeHandler(handler) |
| 120 | + |
| 121 | + # A failed update does not record its fields as configured. |
| 122 | + vehicle = make_vehicle(VIN_A, [FAILED]) |
| 123 | + await vehicle.update_config({"fields": {"BatteryLevel": None}}) |
| 124 | + results.append( |
| 125 | + check( |
| 126 | + "a failed update records no fields", |
| 127 | + vehicle.fields == {}, |
| 128 | + f"fields {sorted(vehicle.fields)}", |
| 129 | + ) |
| 130 | + ) |
| 131 | + |
| 132 | + # prefer_typed=False must be recorded as False, not coerced to True. |
| 133 | + vehicle = make_vehicle(VIN_A, [ACCEPTED]) |
| 134 | + await vehicle.prefer_typed(False) |
| 135 | + results.append( |
| 136 | + check( |
| 137 | + "prefer_typed(False) recorded as False", |
| 138 | + vehicle.preferTyped is False, |
| 139 | + f"got {vehicle.preferTyped!r}", |
| 140 | + ) |
| 141 | + ) |
| 142 | + |
| 143 | + # Pending config must not be shared between vehicles. |
| 144 | + first = make_vehicle(VIN_A, [FAILED]) |
| 145 | + second = make_vehicle(VIN_B, [ACCEPTED]) |
| 146 | + await first.update_config({"fields": {"RouteLastUpdated": None}}) |
| 147 | + await second.update_config({"fields": {"BatteryLevel": None}}) |
| 148 | + sent = second.sent[0]["fields"] # type: ignore[attr-defined] |
| 149 | + results.append( |
| 150 | + check( |
| 151 | + "one vehicle's pending config does not leak to another", |
| 152 | + "RouteLastUpdated" not in sent, |
| 153 | + f"sent {sorted(sent)}", |
| 154 | + ) |
| 155 | + ) |
| 156 | + results.append( |
| 157 | + check( |
| 158 | + "one vehicle's recorded fields do not leak to another", |
| 159 | + "BatteryLevel" not in first.fields, |
| 160 | + f"first {sorted(first.fields)}, second {sorted(second.fields)}", |
| 161 | + ) |
| 162 | + ) |
| 163 | + |
| 164 | + print("-" * 72) |
| 165 | + print("ALL PASS" if all(results) else "FAILURES PRESENT") |
| 166 | + if not all(results): |
| 167 | + raise SystemExit(1) |
| 168 | + |
| 169 | + |
| 170 | +if __name__ == "__main__": |
| 171 | + asyncio.run(main()) |
0 commit comments