Initial commit with translated description
This commit is contained in:
10
tests/__init__.py
Normal file
10
tests/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Test package for my-tesla.
|
||||
|
||||
We explicitly disable bytecode writing so running tests doesn't create __pycache__
|
||||
folders inside the repo (keeps the private repo clean + avoids accidental noisy
|
||||
artifacts).
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
40
tests/test_charge_port_status.py
Normal file
40
tests/test_charge_port_status.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow importing scripts/tesla.py as a module
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class ChargePortStatusTests(unittest.TestCase):
|
||||
def test_charge_port_status_json_shape(self):
|
||||
vehicle = {"display_name": "Test Car", "state": "online"}
|
||||
data = {
|
||||
"charge_state": {
|
||||
"charge_port_door_open": True,
|
||||
"charge_port_latch": "Engaged",
|
||||
"conn_charge_cable": "SAE",
|
||||
"charging_state": "Charging",
|
||||
# extra fields should be ignored
|
||||
"battery_level": 50,
|
||||
},
|
||||
# drive_state/location must not be pulled into the object
|
||||
"drive_state": {"latitude": 1.23, "longitude": 4.56},
|
||||
}
|
||||
|
||||
out = tesla._charge_port_status_json(vehicle, data)
|
||||
self.assertEqual(out["display_name"], "Test Car")
|
||||
self.assertEqual(out["state"], "online")
|
||||
self.assertEqual(out["charge_port_door_open"], True)
|
||||
self.assertEqual(out["charge_port_latch"], "Engaged")
|
||||
self.assertEqual(out["conn_charge_cable"], "SAE")
|
||||
self.assertEqual(out["charging_state"], "Charging")
|
||||
self.assertNotIn("drive_state", out)
|
||||
self.assertNotIn("latitude", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
44
tests/test_charge_status_json.py
Normal file
44
tests/test_charge_status_json.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import unittest
|
||||
|
||||
from scripts.tesla import _charge_status_json
|
||||
|
||||
|
||||
class TestChargeStatusJson(unittest.TestCase):
|
||||
def test_charge_status_json_includes_useful_fields(self):
|
||||
charge = {
|
||||
'battery_level': 55,
|
||||
'battery_range': 123.4,
|
||||
'usable_battery_level': 52,
|
||||
'charging_state': 'Charging',
|
||||
'charge_limit_soc': 80,
|
||||
'time_to_full_charge': 1.5,
|
||||
'charge_rate': 22,
|
||||
'charger_power': 7,
|
||||
'charger_voltage': 240,
|
||||
'charger_actual_current': 29,
|
||||
'scheduled_charging_start_time': 60,
|
||||
'scheduled_charging_mode': 'DepartBy',
|
||||
'scheduled_charging_pending': True,
|
||||
'charge_port_door_open': True,
|
||||
'conn_charge_cable': 'SAEJ1772',
|
||||
}
|
||||
|
||||
out = _charge_status_json(charge)
|
||||
|
||||
# Sanity checks: key presence and values pass through.
|
||||
self.assertEqual(out.get('battery_level'), 55)
|
||||
self.assertEqual(out.get('usable_battery_level'), 52)
|
||||
self.assertEqual(out.get('charger_power'), 7)
|
||||
self.assertEqual(out.get('charger_voltage'), 240)
|
||||
self.assertEqual(out.get('charger_actual_current'), 29)
|
||||
self.assertEqual(out.get('charge_port_door_open'), True)
|
||||
self.assertEqual(out.get('conn_charge_cable'), 'SAEJ1772')
|
||||
|
||||
def test_charge_status_json_handles_none(self):
|
||||
out = _charge_status_json(None)
|
||||
self.assertIsInstance(out, dict)
|
||||
self.assertIsNone(out.get('battery_level'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
99
tests/test_cmd_climate_defrost.py
Normal file
99
tests/test_cmd_climate_defrost.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
# Import the tesla script as a module
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class DummyVehicle:
|
||||
def __init__(self, display_name="Test Car", state="online"):
|
||||
self._display_name = display_name
|
||||
self._state = state
|
||||
self.command_calls = []
|
||||
|
||||
def __getitem__(self, k):
|
||||
if k == "display_name":
|
||||
return self._display_name
|
||||
if k == "state":
|
||||
return self._state
|
||||
raise KeyError(k)
|
||||
|
||||
def get(self, k, default=None):
|
||||
if k == "display_name":
|
||||
return self._display_name
|
||||
if k == "state":
|
||||
return self._state
|
||||
return default
|
||||
|
||||
def command(self, name, **kwargs):
|
||||
self.command_calls.append((name, kwargs))
|
||||
|
||||
|
||||
class CmdClimateDefrostTests(unittest.TestCase):
|
||||
def test_climate_defrost_on_calls_endpoint(self):
|
||||
vehicle = DummyVehicle()
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "defrost"
|
||||
args.value = "on"
|
||||
args.no_wake = False
|
||||
args.json = False
|
||||
args.celsius = False
|
||||
args.fahrenheit = False
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "wake_vehicle") as wake:
|
||||
tesla.cmd_climate(args)
|
||||
|
||||
wake.assert_called_once()
|
||||
self.assertEqual(vehicle.command_calls, [("SET_PRECONDITIONING_MAX", {"on": True})])
|
||||
|
||||
def test_climate_defrost_off_calls_endpoint(self):
|
||||
vehicle = DummyVehicle()
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "defrost"
|
||||
args.value = "off"
|
||||
args.no_wake = False
|
||||
args.json = False
|
||||
args.celsius = False
|
||||
args.fahrenheit = False
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "wake_vehicle") as wake:
|
||||
tesla.cmd_climate(args)
|
||||
|
||||
wake.assert_called_once()
|
||||
self.assertEqual(vehicle.command_calls, [("SET_PRECONDITIONING_MAX", {"on": False})])
|
||||
|
||||
def test_climate_defrost_requires_value(self):
|
||||
vehicle = DummyVehicle()
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "defrost"
|
||||
args.value = None
|
||||
args.no_wake = False
|
||||
args.json = False
|
||||
args.celsius = False
|
||||
args.fahrenheit = False
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "wake_vehicle"):
|
||||
with self.assertRaises(ValueError):
|
||||
tesla.cmd_climate(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
99
tests/test_cmd_climate_status.py
Normal file
99
tests/test_cmd_climate_status.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import io
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from unittest import mock
|
||||
|
||||
# Import the tesla script as a module
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class DummyVehicle:
|
||||
def __init__(self, display_name="Test Car", state="online", data=None):
|
||||
self._display_name = display_name
|
||||
self._state = state
|
||||
self._data = data or {}
|
||||
|
||||
def __getitem__(self, k):
|
||||
if k == "display_name":
|
||||
return self._display_name
|
||||
if k == "state":
|
||||
return self._state
|
||||
raise KeyError(k)
|
||||
|
||||
def get(self, k, default=None):
|
||||
if k == "display_name":
|
||||
return self._display_name
|
||||
if k == "state":
|
||||
return self._state
|
||||
return default
|
||||
|
||||
def get_vehicle_data(self):
|
||||
return self._data
|
||||
|
||||
|
||||
class CmdClimateStatusTests(unittest.TestCase):
|
||||
def test_climate_status_prints_readable_output(self):
|
||||
data = {
|
||||
"climate_state": {
|
||||
"is_climate_on": False,
|
||||
"inside_temp": 20,
|
||||
"outside_temp": 10,
|
||||
"driver_temp_setting": 21,
|
||||
"passenger_temp_setting": 21,
|
||||
}
|
||||
}
|
||||
vehicle = DummyVehicle(data=data)
|
||||
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "status"
|
||||
args.no_wake = True
|
||||
args.json = False
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "_ensure_online_or_exit"):
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
tesla.cmd_climate(args)
|
||||
|
||||
out = buf.getvalue()
|
||||
self.assertIn("🚗 Test Car", out)
|
||||
self.assertIn("Climate: Off", out)
|
||||
self.assertIn("Inside:", out)
|
||||
self.assertIn("Outside:", out)
|
||||
self.assertIn("Setpoint:", out)
|
||||
|
||||
def test_climate_status_json_is_json_only(self):
|
||||
data = {"climate_state": {"is_climate_on": True, "inside_temp": 20}}
|
||||
vehicle = DummyVehicle(data=data)
|
||||
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "status"
|
||||
args.no_wake = False
|
||||
args.json = True
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "_ensure_online_or_exit"):
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
tesla.cmd_climate(args)
|
||||
|
||||
out = buf.getvalue().strip()
|
||||
# Should be valid JSON (starts with '{') with no extra human text.
|
||||
self.assertTrue(out.startswith("{"))
|
||||
self.assertIn('"inside_temp_c"', out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
41
tests/test_cmd_mileage_record.py
Normal file
41
tests/test_cmd_mileage_record.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
from pathlib import Path
|
||||
|
||||
import scripts.tesla as tesla
|
||||
|
||||
|
||||
class CmdMileageRecordTests(unittest.TestCase):
|
||||
def test_record_skips_when_no_wake_and_asleep(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "mileage.sqlite"
|
||||
|
||||
class FakeVehicle(dict):
|
||||
def get_vehicle_data(self):
|
||||
raise AssertionError("should not be called when asleep + no-wake")
|
||||
|
||||
v = FakeVehicle(display_name="Car", id_s="1", state="asleep")
|
||||
|
||||
fake_tesla = SimpleNamespace(vehicle_list=lambda: [v])
|
||||
|
||||
args = SimpleNamespace(
|
||||
action="record",
|
||||
db=str(db),
|
||||
no_wake=True,
|
||||
auto_wake_after_hours=24.0,
|
||||
json=True,
|
||||
email=None,
|
||||
car=None,
|
||||
)
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla", return_value=fake_tesla), \
|
||||
mock.patch.object(tesla, "require_email", return_value="x@example.com"), \
|
||||
mock.patch.object(tesla, "wake_vehicle", return_value=False):
|
||||
# Should not raise
|
||||
tesla.cmd_mileage(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
106
tests/test_cmd_seats.py
Normal file
106
tests/test_cmd_seats.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import io
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from unittest import mock
|
||||
|
||||
# Import the tesla script as a module
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class DummyVehicle:
|
||||
def __init__(self, display_name="Test Car", state="online", vehicle_data=None):
|
||||
self._display_name = display_name
|
||||
self._state = state
|
||||
self._vehicle_data = vehicle_data or {}
|
||||
self.command_calls = []
|
||||
|
||||
def __getitem__(self, k):
|
||||
if k == "display_name":
|
||||
return self._display_name
|
||||
if k == "state":
|
||||
return self._state
|
||||
raise KeyError(k)
|
||||
|
||||
def get(self, k, default=None):
|
||||
if k == "display_name":
|
||||
return self._display_name
|
||||
if k == "state":
|
||||
return self._state
|
||||
return default
|
||||
|
||||
def get_vehicle_data(self):
|
||||
return self._vehicle_data
|
||||
|
||||
def command(self, name, **kwargs):
|
||||
self.command_calls.append((name, kwargs))
|
||||
|
||||
|
||||
class SeatsTests(unittest.TestCase):
|
||||
def test_parse_seat_heater_names(self):
|
||||
self.assertEqual(tesla._parse_seat_heater("driver"), 0)
|
||||
self.assertEqual(tesla._parse_seat_heater("passenger"), 1)
|
||||
self.assertEqual(tesla._parse_seat_heater("rear-left"), 2)
|
||||
self.assertEqual(tesla._parse_seat_heater("rear-center"), 3)
|
||||
self.assertEqual(tesla._parse_seat_heater("rear-right"), 4)
|
||||
self.assertEqual(tesla._parse_seat_heater("3rd-left"), 5)
|
||||
self.assertEqual(tesla._parse_seat_heater("3rd-right"), 6)
|
||||
self.assertEqual(tesla._parse_seat_heater("0"), 0)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
tesla._parse_seat_heater("nope")
|
||||
|
||||
def test_seats_status_json_is_json_only(self):
|
||||
vehicle = DummyVehicle(vehicle_data={
|
||||
"climate_state": {
|
||||
"seat_heater_left": 3,
|
||||
"seat_heater_right": 1,
|
||||
}
|
||||
})
|
||||
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "status"
|
||||
args.no_wake = True
|
||||
args.json = True
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "_ensure_online_or_exit"):
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
tesla.cmd_seats(args)
|
||||
|
||||
out = buf.getvalue().strip()
|
||||
self.assertTrue(out.startswith("{"))
|
||||
self.assertIn('"seat_heater_left"', out)
|
||||
self.assertIn('"seat_heater_right"', out)
|
||||
|
||||
def test_seats_set_calls_endpoint(self):
|
||||
vehicle = DummyVehicle()
|
||||
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "set"
|
||||
args.seat = "driver"
|
||||
args.level = "2"
|
||||
args.yes = True
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "wake_vehicle") as wake:
|
||||
tesla.cmd_seats(args)
|
||||
|
||||
wake.assert_called_once()
|
||||
self.assertEqual(vehicle.command_calls, [("REMOTE_SEAT_HEATER_REQUEST", {"heater": 0, "level": 2})])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
73
tests/test_cmd_status_summary.py
Normal file
73
tests/test_cmd_status_summary.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import io
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from unittest import mock
|
||||
|
||||
# Import the tesla script as a module
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class DummyVehicle:
|
||||
def __init__(self, display_name="Test Car", state="online", data=None):
|
||||
self._display_name = display_name
|
||||
self._state = state
|
||||
self._data = data or {}
|
||||
|
||||
def __getitem__(self, k):
|
||||
if k == "display_name":
|
||||
return self._display_name
|
||||
if k == "state":
|
||||
return self._state
|
||||
raise KeyError(k)
|
||||
|
||||
def get(self, k, default=None):
|
||||
if k == "display_name":
|
||||
return self._display_name
|
||||
if k == "state":
|
||||
return self._state
|
||||
return default
|
||||
|
||||
def get_vehicle_data(self):
|
||||
return self._data
|
||||
|
||||
|
||||
class CmdStatusSummaryTests(unittest.TestCase):
|
||||
def test_status_summary_prints_summary_and_details(self):
|
||||
data = {
|
||||
"charge_state": {"battery_level": 55, "battery_range": 123.4, "charging_state": "Stopped"},
|
||||
"climate_state": {"inside_temp": 20, "is_climate_on": False},
|
||||
"vehicle_state": {"locked": True},
|
||||
}
|
||||
vehicle = DummyVehicle(data=data)
|
||||
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.no_wake = False
|
||||
args.summary = True
|
||||
args.json = False
|
||||
|
||||
# Patch networky bits.
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "_ensure_online_or_exit"):
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
tesla.cmd_status(args)
|
||||
|
||||
out = buf.getvalue()
|
||||
# Summary line
|
||||
self.assertIn("🚗 Test Car", out)
|
||||
self.assertIn("🔋 55%", out)
|
||||
# Detailed section should still appear
|
||||
self.assertIn("Battery: 55% (123 mi)", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
126
tests/test_cmd_windows.py
Normal file
126
tests/test_cmd_windows.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
# Import the tesla script as a module
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class DummyVehicle:
|
||||
def __init__(self, display_name="Test Car", state="online", vehicle_data=None):
|
||||
self._display_name = display_name
|
||||
self._state = state
|
||||
self._vehicle_data = vehicle_data or {}
|
||||
self.command_calls = []
|
||||
|
||||
def __getitem__(self, k):
|
||||
if k == "display_name":
|
||||
return self._display_name
|
||||
if k == "state":
|
||||
return self._state
|
||||
raise KeyError(k)
|
||||
|
||||
def get(self, k, default=None):
|
||||
if k == "display_name":
|
||||
return self._display_name
|
||||
if k == "state":
|
||||
return self._state
|
||||
return default
|
||||
|
||||
def get_vehicle_data(self):
|
||||
return self._vehicle_data
|
||||
|
||||
def command(self, name, **kwargs):
|
||||
self.command_calls.append((name, kwargs))
|
||||
|
||||
|
||||
class CmdWindowsTests(unittest.TestCase):
|
||||
def test_windows_status_outputs_open_states(self):
|
||||
vehicle = DummyVehicle(vehicle_data={
|
||||
'vehicle_state': {
|
||||
'fd_window': 0,
|
||||
'fp_window': 1,
|
||||
'rd_window': 0,
|
||||
'rp_window': None,
|
||||
}
|
||||
})
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "status"
|
||||
args.yes = False
|
||||
args.no_wake = True
|
||||
args.json = True
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "wake_vehicle", return_value=True) as wake, \
|
||||
mock.patch("builtins.print") as p:
|
||||
tesla.cmd_windows(args)
|
||||
|
||||
# Should not require --yes for status
|
||||
wake.assert_called_once_with(vehicle, allow_wake=False)
|
||||
printed = "\n".join(str(call.args[0]) for call in p.call_args_list)
|
||||
self.assertIn('"front_driver": "Closed"', printed)
|
||||
self.assertIn('"front_passenger": "Open"', printed)
|
||||
|
||||
def test_windows_vent_calls_endpoint(self):
|
||||
vehicle = DummyVehicle()
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "vent"
|
||||
args.yes = True
|
||||
args.no_wake = False
|
||||
args.json = False
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "wake_vehicle") as wake:
|
||||
tesla.cmd_windows(args)
|
||||
|
||||
wake.assert_called_once()
|
||||
self.assertEqual(vehicle.command_calls, [("WINDOW_CONTROL", {"command": "vent", "lat": 0, "lon": 0})])
|
||||
|
||||
def test_windows_close_calls_endpoint(self):
|
||||
vehicle = DummyVehicle()
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "close"
|
||||
args.yes = True
|
||||
args.no_wake = False
|
||||
args.json = False
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "wake_vehicle") as wake:
|
||||
tesla.cmd_windows(args)
|
||||
|
||||
wake.assert_called_once()
|
||||
self.assertEqual(vehicle.command_calls, [("WINDOW_CONTROL", {"command": "close", "lat": 0, "lon": 0})])
|
||||
|
||||
def test_windows_unknown_action_raises(self):
|
||||
vehicle = DummyVehicle()
|
||||
args = mock.Mock()
|
||||
args.car = None
|
||||
args.action = "nope"
|
||||
args.yes = True
|
||||
args.no_wake = False
|
||||
args.json = False
|
||||
|
||||
with mock.patch.object(tesla, "get_tesla"), \
|
||||
mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \
|
||||
mock.patch.object(tesla, "require_email", return_value="test@example.com"), \
|
||||
mock.patch.object(tesla, "wake_vehicle"):
|
||||
with self.assertRaises(ValueError):
|
||||
tesla.cmd_windows(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
32
tests/test_defaults_permissions.py
Normal file
32
tests/test_defaults_permissions.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import scripts.tesla as tesla
|
||||
|
||||
|
||||
class DefaultsPermissionsTests(unittest.TestCase):
|
||||
def test_save_defaults_sets_0600_permissions_best_effort(self):
|
||||
# On Windows this may not apply; but this repo targets macOS/Linux.
|
||||
if os.name != "posix":
|
||||
self.skipTest("POSIX-only permissions")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = Path(td) / "defaults.json"
|
||||
|
||||
# Patch the module-level DEFAULTS_FILE for this test.
|
||||
old = tesla.DEFAULTS_FILE
|
||||
try:
|
||||
tesla.DEFAULTS_FILE = p
|
||||
tesla.save_defaults({"default_car": "Test"})
|
||||
|
||||
mode = stat.S_IMODE(p.stat().st_mode)
|
||||
self.assertEqual(mode, 0o600)
|
||||
finally:
|
||||
tesla.DEFAULTS_FILE = old
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
119
tests/test_formatting.py
Normal file
119
tests/test_formatting.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow importing scripts/tesla.py as a module
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class FormattingTests(unittest.TestCase):
|
||||
def test_c_to_f(self):
|
||||
self.assertAlmostEqual(tesla._c_to_f(0), 32)
|
||||
self.assertAlmostEqual(tesla._c_to_f(20), 68)
|
||||
|
||||
def test_fmt_temp_pair(self):
|
||||
self.assertIsNone(tesla._fmt_temp_pair(None))
|
||||
self.assertEqual(tesla._fmt_temp_pair(20), "20°C (68°F)")
|
||||
|
||||
def test_tire_pressure_formatting(self):
|
||||
# 2.90 bar is ~42 psi (common on Model 3)
|
||||
psi = tesla._bar_to_psi(2.90)
|
||||
self.assertIsNotNone(psi)
|
||||
self.assertTrue(40 <= psi <= 45)
|
||||
self.assertEqual(tesla._fmt_tire_pressure(2.9), "2.90 bar (42 psi)")
|
||||
self.assertIsNone(tesla._fmt_tire_pressure(None))
|
||||
|
||||
def test_short_status_contains_expected_bits(self):
|
||||
vehicle = {"display_name": "Test Car"}
|
||||
data = {
|
||||
"charge_state": {
|
||||
"battery_level": 55,
|
||||
"battery_range": 123.4,
|
||||
"charging_state": "Stopped",
|
||||
},
|
||||
"climate_state": {"inside_temp": 20, "is_climate_on": False},
|
||||
"vehicle_state": {"locked": True},
|
||||
}
|
||||
|
||||
out = tesla._short_status(vehicle, data)
|
||||
self.assertIn("🚗 Test Car", out)
|
||||
self.assertIn("Locked", out)
|
||||
self.assertIn("55%", out)
|
||||
self.assertIn("123 mi", out)
|
||||
self.assertIn("⚡ Stopped", out)
|
||||
self.assertIn("68°F", out)
|
||||
self.assertIn("Off", out)
|
||||
|
||||
def test_fmt_minutes_hhmm(self):
|
||||
self.assertEqual(tesla._fmt_minutes_hhmm(0), "00:00")
|
||||
self.assertEqual(tesla._fmt_minutes_hhmm(60), "01:00")
|
||||
self.assertEqual(tesla._fmt_minutes_hhmm(23 * 60 + 59), "23:59")
|
||||
self.assertIsNone(tesla._fmt_minutes_hhmm(-1))
|
||||
self.assertIsNone(tesla._fmt_minutes_hhmm("nope"))
|
||||
|
||||
def test_parse_hhmm(self):
|
||||
self.assertEqual(tesla._parse_hhmm("00:00"), 0)
|
||||
self.assertEqual(tesla._parse_hhmm("01:30"), 90)
|
||||
self.assertEqual(tesla._parse_hhmm("23:59"), 23 * 60 + 59)
|
||||
with self.assertRaises(ValueError):
|
||||
tesla._parse_hhmm("24:00")
|
||||
with self.assertRaises(ValueError):
|
||||
tesla._parse_hhmm("12:60")
|
||||
with self.assertRaises(ValueError):
|
||||
tesla._parse_hhmm("1230")
|
||||
|
||||
def test_report_is_one_screen(self):
|
||||
vehicle = {"display_name": "Test Car", "state": "online"}
|
||||
data = {
|
||||
"charge_state": {
|
||||
"battery_level": 80,
|
||||
"usable_battery_level": 78,
|
||||
"battery_range": 250.2,
|
||||
"charging_state": "Charging",
|
||||
"charge_limit_soc": 90,
|
||||
"time_to_full_charge": 1.5,
|
||||
"charge_rate": 30,
|
||||
"scheduled_charging_start_time": 120,
|
||||
"scheduled_charging_pending": True,
|
||||
},
|
||||
"climate_state": {"inside_temp": 21, "outside_temp": 10, "is_climate_on": True},
|
||||
"vehicle_state": {
|
||||
"locked": False,
|
||||
"sentry_mode": True,
|
||||
"odometer": 12345.6,
|
||||
"tpms_pressure_fl": 2.9,
|
||||
"tpms_pressure_fr": 2.9,
|
||||
"tpms_pressure_rl": 2.8,
|
||||
"tpms_pressure_rr": 2.8,
|
||||
},
|
||||
}
|
||||
|
||||
out = tesla._report(vehicle, data)
|
||||
# Basic structure
|
||||
self.assertTrue(out.startswith("🚗 Test Car"))
|
||||
self.assertIn("State: online", out)
|
||||
self.assertIn("Locked: No", out)
|
||||
self.assertIn("Sentry: On", out)
|
||||
self.assertIn("Battery: 80% (250 mi)", out)
|
||||
self.assertIn("Usable battery: 78%", out)
|
||||
self.assertIn("Charging: Charging", out)
|
||||
self.assertIn("Scheduled charging:", out)
|
||||
self.assertIn("02:00", out)
|
||||
self.assertIn("Inside:", out)
|
||||
self.assertIn("Outside:", out)
|
||||
self.assertIn("Tires (TPMS):", out)
|
||||
self.assertIn("FL 2.90 bar (42 psi)", out)
|
||||
self.assertIn("RL 2.80 bar (41 psi)", out)
|
||||
self.assertIn("Odometer: 12346 mi", out)
|
||||
|
||||
def test_round_coord(self):
|
||||
self.assertEqual(tesla._round_coord(37.123456, 2), 37.12)
|
||||
self.assertEqual(tesla._round_coord("-122.123456", 2), -122.12)
|
||||
self.assertIsNone(tesla._round_coord("nope", 2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
27
tests/test_location_rounding.py
Normal file
27
tests/test_location_rounding.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import unittest
|
||||
|
||||
from scripts.tesla import _round_coord
|
||||
|
||||
|
||||
class TestLocationRounding(unittest.TestCase):
|
||||
def test_round_coord_basic(self):
|
||||
self.assertEqual(_round_coord(37.123456, 2), 37.12)
|
||||
self.assertEqual(_round_coord(-122.987654, 2), -122.99)
|
||||
|
||||
def test_round_coord_digits_range(self):
|
||||
# Reject overly-precise / invalid digit counts.
|
||||
self.assertIsNone(_round_coord(1.2345, -1))
|
||||
self.assertIsNone(_round_coord(1.2345, 7))
|
||||
|
||||
# Allow the full supported range.
|
||||
self.assertEqual(_round_coord(1.2345678, 0), 1.0)
|
||||
self.assertEqual(_round_coord(1.2345678, 6), 1.234568)
|
||||
|
||||
def test_round_coord_invalid_inputs(self):
|
||||
self.assertIsNone(_round_coord(None, 2))
|
||||
self.assertIsNone(_round_coord("", 2))
|
||||
self.assertIsNone(_round_coord(1.23, "nope"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
46
tests/test_mileage_db.py
Normal file
46
tests/test_mileage_db.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import scripts.tesla as tesla
|
||||
|
||||
|
||||
class MileageDbTests(unittest.TestCase):
|
||||
def test_init_and_insert(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "mileage.sqlite"
|
||||
tesla.mileage_init_db(db)
|
||||
|
||||
conn = tesla._db_connect(db)
|
||||
try:
|
||||
tesla.mileage_insert_point(
|
||||
conn,
|
||||
ts_utc=1700000000,
|
||||
vehicle_id="123",
|
||||
vehicle_name="Car",
|
||||
odometer_mi=100.0,
|
||||
state="online",
|
||||
source="test",
|
||||
note=None,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
last = tesla.mileage_last_success_ts(conn, "123")
|
||||
self.assertEqual(last, 1700000000)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_resolve_db_path_env(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = str(Path(td) / "x.sqlite")
|
||||
os.environ["MY_TESLA_MILEAGE_DB"] = p
|
||||
try:
|
||||
out = tesla.resolve_mileage_db_path(None)
|
||||
self.assertEqual(str(out), p)
|
||||
finally:
|
||||
os.environ.pop("MY_TESLA_MILEAGE_DB", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
59
tests/test_mileage_export_filter.py
Normal file
59
tests/test_mileage_export_filter.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from scripts.tesla import mileage_fetch_points, resolve_since_ts
|
||||
|
||||
|
||||
class TestMileageExportFilter(unittest.TestCase):
|
||||
def test_resolve_since_ts_prefers_explicit_ts(self):
|
||||
self.assertEqual(resolve_since_ts(since_ts=123, since_days=7), 123)
|
||||
|
||||
def test_resolve_since_ts_since_days(self):
|
||||
with patch("scripts.tesla.time.time", return_value=1000):
|
||||
# 1 day = 86400 seconds
|
||||
self.assertEqual(resolve_since_ts(since_days=1), 1000 - 86400)
|
||||
|
||||
def test_mileage_fetch_points_filters(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE mileage_points (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts_utc INTEGER NOT NULL,
|
||||
vehicle_id TEXT,
|
||||
vehicle_name TEXT,
|
||||
odometer_mi REAL,
|
||||
state TEXT,
|
||||
source TEXT,
|
||||
note TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Insert three points
|
||||
conn.execute(
|
||||
"INSERT INTO mileage_points(ts_utc, vehicle_id, vehicle_name, odometer_mi, state, source, note) VALUES (?,?,?,?,?,?,?)",
|
||||
(10, "v1", "Car", 1.0, "online", "test", None),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO mileage_points(ts_utc, vehicle_id, vehicle_name, odometer_mi, state, source, note) VALUES (?,?,?,?,?,?,?)",
|
||||
(20, "v1", "Car", 2.0, "online", "test", None),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO mileage_points(ts_utc, vehicle_id, vehicle_name, odometer_mi, state, source, note) VALUES (?,?,?,?,?,?,?)",
|
||||
(30, "v1", "Car", 3.0, "online", "test", None),
|
||||
)
|
||||
|
||||
all_rows = mileage_fetch_points(conn)
|
||||
self.assertEqual([r[0] for r in all_rows], [10, 20, 30])
|
||||
|
||||
filtered = mileage_fetch_points(conn, since_ts=21)
|
||||
self.assertEqual([r[0] for r in filtered], [30])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
36
tests/test_no_wake.py
Normal file
36
tests/test_no_wake.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
# Allow importing scripts/tesla.py as a module
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class DummyVehicle(dict):
|
||||
def sync_wake_up(self):
|
||||
raise AssertionError("sync_wake_up should not be called in this test")
|
||||
|
||||
|
||||
class NoWakeTests(unittest.TestCase):
|
||||
def test_wake_vehicle_allow_wake_false_offline_returns_false(self):
|
||||
v = DummyVehicle(state="asleep", display_name="Test Car")
|
||||
self.assertFalse(tesla.wake_vehicle(v, allow_wake=False))
|
||||
|
||||
def test_wake_vehicle_online_returns_true(self):
|
||||
v = DummyVehicle(state="online", display_name="Test Car")
|
||||
self.assertTrue(tesla.wake_vehicle(v, allow_wake=False))
|
||||
|
||||
def test_ensure_online_or_exit_exits_3_when_no_wake(self):
|
||||
v = DummyVehicle(state="asleep", display_name="Test Car")
|
||||
with mock.patch.object(tesla, "wake_vehicle", return_value=False):
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
tesla._ensure_online_or_exit(v, allow_wake=False)
|
||||
self.assertEqual(ctx.exception.code, 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
25
tests/test_openings_format.py
Normal file
25
tests/test_openings_format.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import unittest
|
||||
|
||||
from scripts.tesla import _fmt_open
|
||||
|
||||
|
||||
class TestOpeningsFormat(unittest.TestCase):
|
||||
def test_fmt_open_none(self):
|
||||
self.assertIsNone(_fmt_open(None))
|
||||
|
||||
def test_fmt_open_bool(self):
|
||||
self.assertEqual(_fmt_open(True), "Open")
|
||||
self.assertEqual(_fmt_open(False), "Closed")
|
||||
|
||||
def test_fmt_open_intish(self):
|
||||
self.assertEqual(_fmt_open(1), "Open")
|
||||
self.assertEqual(_fmt_open(0), "Closed")
|
||||
self.assertEqual(_fmt_open("1"), "Open")
|
||||
self.assertEqual(_fmt_open("0"), "Closed")
|
||||
|
||||
def test_fmt_open_invalid(self):
|
||||
self.assertIsNone(_fmt_open("maybe"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
37
tests/test_report_charging_power.py
Normal file
37
tests/test_report_charging_power.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow importing scripts/tesla.py as a module
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class ReportChargingPowerTests(unittest.TestCase):
|
||||
def test_report_includes_charging_power_details_when_present(self):
|
||||
vehicle = {"display_name": "Test Car", "state": "online"}
|
||||
data = {
|
||||
"charge_state": {
|
||||
"battery_level": 50,
|
||||
"battery_range": 123.4,
|
||||
"charging_state": "Charging",
|
||||
"charger_power": 7,
|
||||
"charger_voltage": 240,
|
||||
"charger_actual_current": 30,
|
||||
},
|
||||
"climate_state": {},
|
||||
"vehicle_state": {},
|
||||
}
|
||||
|
||||
out = tesla._report(vehicle, data)
|
||||
self.assertIn("Charging: Charging", out)
|
||||
self.assertIn("Charging power:", out)
|
||||
self.assertIn("7 kW", out)
|
||||
self.assertIn("240V", out)
|
||||
self.assertIn("30A", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
65
tests/test_report_json.py
Normal file
65
tests/test_report_json.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow importing scripts/tesla.py as a module
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class ReportJsonTests(unittest.TestCase):
|
||||
def test_report_json_is_sanitized(self):
|
||||
vehicle = {"display_name": "Test Car", "state": "online"}
|
||||
data = {
|
||||
"charge_state": {
|
||||
"battery_level": 80,
|
||||
"battery_range": 250.2,
|
||||
"charging_state": "Charging",
|
||||
"charge_limit_soc": 90,
|
||||
"time_to_full_charge": 1.5,
|
||||
"charge_rate": 30,
|
||||
"charge_port_door_open": True,
|
||||
"conn_charge_cable": "SAE",
|
||||
"scheduled_charging_mode": "Start",
|
||||
"scheduled_charging_pending": True,
|
||||
"scheduled_charging_start_time": 60, # 01:00
|
||||
},
|
||||
"climate_state": {
|
||||
"inside_temp": 21,
|
||||
"outside_temp": 10,
|
||||
"is_climate_on": True,
|
||||
"seat_heater_left": 3,
|
||||
"seat_heater_right": 1,
|
||||
},
|
||||
"vehicle_state": {"locked": False, "sentry_mode": True, "odometer": 12345.6},
|
||||
# This is intentionally present in raw vehicle_data, but should not show up in report JSON.
|
||||
"drive_state": {"latitude": 37.1234, "longitude": -122.5678},
|
||||
}
|
||||
|
||||
out = tesla._report_json(vehicle, data)
|
||||
self.assertIn("vehicle", out)
|
||||
self.assertEqual(out["vehicle"]["display_name"], "Test Car")
|
||||
|
||||
# Must not include raw drive_state/location
|
||||
self.assertNotIn("drive_state", out)
|
||||
self.assertNotIn("location", out)
|
||||
self.assertNotIn("latitude", str(out))
|
||||
self.assertNotIn("longitude", str(out))
|
||||
|
||||
# Expected useful bits
|
||||
self.assertEqual(out["battery"]["level_percent"], 80)
|
||||
self.assertEqual(out["charging"]["charging_state"], "Charging")
|
||||
self.assertEqual(out["charging"]["charge_port_door_open"], True)
|
||||
self.assertEqual(out["charging"]["conn_charge_cable"], "SAE")
|
||||
self.assertEqual(out["scheduled_charging"]["start_time_hhmm"], "01:00")
|
||||
self.assertEqual(out["security"]["locked"], False)
|
||||
|
||||
# Seat heaters should be present when vehicle reports them
|
||||
self.assertIn("seat_heaters", out["climate"])
|
||||
self.assertEqual(out["climate"]["seat_heaters"]["seat_heater_left"], 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
46
tests/test_report_openings.py
Normal file
46
tests/test_report_openings.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow importing scripts/tesla.py as a module
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class ReportOpeningsTests(unittest.TestCase):
|
||||
def test_report_includes_openings_when_fields_present(self):
|
||||
vehicle = {"display_name": "Test Car", "state": "online"}
|
||||
data = {
|
||||
"charge_state": {"battery_level": 50, "battery_range": 123.4},
|
||||
"climate_state": {"inside_temp": 20, "outside_temp": 10, "is_climate_on": False},
|
||||
"vehicle_state": {
|
||||
"locked": True,
|
||||
"sentry_mode": False,
|
||||
"df": 1, # open
|
||||
"rt": 0, # closed
|
||||
},
|
||||
}
|
||||
|
||||
out = tesla._report(vehicle, data)
|
||||
self.assertIn("Openings:", out)
|
||||
self.assertIn("Driver front door", out)
|
||||
|
||||
def test_report_json_openings_omits_when_unknown(self):
|
||||
vehicle = {"display_name": "Test Car", "state": "online"}
|
||||
data = {"vehicle_state": {"locked": True}}
|
||||
out = tesla._report_json(vehicle, data)
|
||||
self.assertNotIn("openings", out)
|
||||
|
||||
def test_report_json_openings_all_closed(self):
|
||||
vehicle = {"display_name": "Test Car", "state": "online"}
|
||||
data = {"vehicle_state": {"df": 0, "rt": 0}}
|
||||
out = tesla._report_json(vehicle, data)
|
||||
self.assertIn("openings", out)
|
||||
self.assertEqual(out["openings"]["open"], [])
|
||||
self.assertEqual(out["openings"]["all_closed"], True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
34
tests/test_report_privacy.py
Normal file
34
tests/test_report_privacy.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow importing scripts/tesla.py as a module
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class ReportPrivacyTests(unittest.TestCase):
|
||||
def test_report_string_does_not_leak_location_fields(self):
|
||||
vehicle = {"display_name": "Test Car", "state": "online"}
|
||||
data = {
|
||||
"charge_state": {"battery_level": 50, "battery_range": 123.4},
|
||||
"climate_state": {"inside_temp": 20, "outside_temp": 10, "is_climate_on": False},
|
||||
"vehicle_state": {"locked": True, "sentry_mode": False},
|
||||
# Raw vehicle_data can include precise coords; report output must not echo them.
|
||||
"drive_state": {"latitude": 37.123456, "longitude": -122.987654},
|
||||
}
|
||||
|
||||
out = tesla._report(vehicle, data)
|
||||
|
||||
# No location fields or raw coordinate strings should appear.
|
||||
self.assertNotIn("drive_state", out)
|
||||
self.assertNotIn("latitude", out)
|
||||
self.assertNotIn("longitude", out)
|
||||
self.assertNotIn("37.123", out)
|
||||
self.assertNotIn("-122.987", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
29
tests/test_report_seat_heaters.py
Normal file
29
tests/test_report_seat_heaters.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow importing scripts/tesla.py as a module
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class ReportSeatHeatersTests(unittest.TestCase):
|
||||
def test_report_includes_seat_heaters_when_available(self):
|
||||
vehicle = {"display_name": "Test Car", "state": "online"}
|
||||
data = {
|
||||
"charge_state": {"battery_level": 50, "battery_range": 123.4},
|
||||
"climate_state": {"is_climate_on": True, "seat_heater_left": 3, "seat_heater_right": 1},
|
||||
"vehicle_state": {},
|
||||
}
|
||||
|
||||
out = tesla._report(vehicle, data)
|
||||
self.assertIn("Seat heaters:", out)
|
||||
# Compact labels (D=driver, P=passenger)
|
||||
self.assertIn("D 3", out)
|
||||
self.assertIn("P 1", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
30
tests/test_scheduled_departure_status.py
Normal file
30
tests/test_scheduled_departure_status.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import unittest
|
||||
|
||||
from scripts.tesla import _scheduled_departure_status_json
|
||||
|
||||
|
||||
class TestScheduledDepartureStatus(unittest.TestCase):
|
||||
def test_scheduled_departure_status_json(self):
|
||||
charge = {
|
||||
'scheduled_departure_enabled': True,
|
||||
'scheduled_departure_time': 7 * 60 + 30,
|
||||
'preconditioning_enabled': False,
|
||||
'off_peak_charging_enabled': True,
|
||||
}
|
||||
out = _scheduled_departure_status_json(charge)
|
||||
self.assertEqual(out['scheduled_departure_enabled'], True)
|
||||
self.assertEqual(out['scheduled_departure_time'], 450)
|
||||
self.assertEqual(out['scheduled_departure_time_hhmm'], '07:30')
|
||||
self.assertEqual(out['preconditioning_enabled'], False)
|
||||
self.assertEqual(out['off_peak_charging_enabled'], True)
|
||||
|
||||
def test_scheduled_departure_status_json_missing(self):
|
||||
out = _scheduled_departure_status_json({})
|
||||
# Ensure keys exist with None values for stable JSON schemas.
|
||||
self.assertIn('scheduled_departure_enabled', out)
|
||||
self.assertIn('scheduled_departure_time', out)
|
||||
self.assertIn('scheduled_departure_time_hhmm', out)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
53
tests/test_summary_json.py
Normal file
53
tests/test_summary_json.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow importing scripts/tesla.py as a module
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class SummaryJsonTests(unittest.TestCase):
|
||||
def test_summary_json_is_sanitized(self):
|
||||
vehicle = {"display_name": "Test Car", "state": "online"}
|
||||
data = {
|
||||
"charge_state": {
|
||||
"battery_level": 63,
|
||||
"battery_range": 199.9,
|
||||
"usable_battery_level": 61,
|
||||
"charging_state": "Disconnected",
|
||||
},
|
||||
"climate_state": {
|
||||
"inside_temp": 20,
|
||||
"is_climate_on": False,
|
||||
},
|
||||
"vehicle_state": {"locked": True},
|
||||
# Present in raw vehicle_data, must not show up in summary JSON.
|
||||
"drive_state": {"latitude": 37.1234, "longitude": -122.5678},
|
||||
}
|
||||
|
||||
out = tesla._summary_json(vehicle, data)
|
||||
|
||||
self.assertIn("vehicle", out)
|
||||
self.assertEqual(out["vehicle"]["display_name"], "Test Car")
|
||||
|
||||
# Must not include raw drive_state/location
|
||||
self.assertNotIn("drive_state", out)
|
||||
self.assertNotIn("location", out)
|
||||
self.assertNotIn("latitude", str(out))
|
||||
self.assertNotIn("longitude", str(out))
|
||||
|
||||
# Expected useful bits
|
||||
self.assertEqual(out["battery"]["level_percent"], 63)
|
||||
self.assertEqual(out["battery"]["range_mi"], 199.9)
|
||||
self.assertEqual(out["battery"]["usable_level_percent"], 61)
|
||||
self.assertEqual(out["charging"]["charging_state"], "Disconnected")
|
||||
self.assertEqual(out["security"]["locked"], True)
|
||||
self.assertIn("summary", out)
|
||||
self.assertIsInstance(out["summary"], str)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
51
tests/test_time_helpers.py
Normal file
51
tests/test_time_helpers.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import unittest
|
||||
|
||||
from scripts.tesla import _fmt_minutes_hhmm, _parse_hhmm
|
||||
|
||||
|
||||
class TestTimeHelpers(unittest.TestCase):
|
||||
def test_fmt_minutes_hhmm_basic(self):
|
||||
self.assertEqual(_fmt_minutes_hhmm(0), "00:00")
|
||||
self.assertEqual(_fmt_minutes_hhmm(1), "00:01")
|
||||
self.assertEqual(_fmt_minutes_hhmm(60), "01:00")
|
||||
self.assertEqual(_fmt_minutes_hhmm(23 * 60 + 59), "23:59")
|
||||
|
||||
def test_fmt_minutes_hhmm_wraps_24h(self):
|
||||
# Tesla sometimes uses minutes-from-midnight; be defensive.
|
||||
self.assertEqual(_fmt_minutes_hhmm(24 * 60), "00:00")
|
||||
self.assertEqual(_fmt_minutes_hhmm(25 * 60 + 5), "01:05")
|
||||
|
||||
def test_fmt_minutes_hhmm_invalid(self):
|
||||
self.assertIsNone(_fmt_minutes_hhmm(-1))
|
||||
self.assertIsNone(_fmt_minutes_hhmm(""))
|
||||
self.assertIsNone(_fmt_minutes_hhmm(None))
|
||||
|
||||
def test_parse_hhmm(self):
|
||||
self.assertEqual(_parse_hhmm("00:00"), 0)
|
||||
self.assertEqual(_parse_hhmm("01:05"), 65)
|
||||
self.assertEqual(_parse_hhmm("23:59"), 23 * 60 + 59)
|
||||
|
||||
def test_parse_hhmm_strips(self):
|
||||
self.assertEqual(_parse_hhmm(" 07:30 "), 7 * 60 + 30)
|
||||
|
||||
def test_parse_hhmm_invalid(self):
|
||||
for bad in [
|
||||
None,
|
||||
"",
|
||||
" ",
|
||||
"7:30", # must be zero-padded? actually current parser allows; but still has ':' so ok
|
||||
]:
|
||||
if bad == "7:30":
|
||||
# Current implementation allows single-digit hours; keep behavior.
|
||||
self.assertEqual(_parse_hhmm(bad), 7 * 60 + 30)
|
||||
else:
|
||||
with self.assertRaises(ValueError):
|
||||
_parse_hhmm(bad)
|
||||
|
||||
for bad in ["24:00", "00:60", "-1:00", "ab:cd", "123"]:
|
||||
with self.assertRaises(Exception):
|
||||
_parse_hhmm(bad)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
71
tests/test_vehicle_select.py
Normal file
71
tests/test_vehicle_select.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow importing scripts/tesla.py as a module
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tesla # noqa: E402
|
||||
|
||||
|
||||
class VehicleSelectTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.vehicles = [
|
||||
{"display_name": "My Model 3"},
|
||||
{"display_name": "Road Trip"},
|
||||
{"display_name": "Model Y"},
|
||||
]
|
||||
|
||||
def test_select_vehicle_default_first(self):
|
||||
v = tesla._select_vehicle(self.vehicles, None)
|
||||
self.assertEqual(v["display_name"], "My Model 3")
|
||||
|
||||
def test_select_vehicle_exact_case_insensitive(self):
|
||||
v = tesla._select_vehicle(self.vehicles, "model y")
|
||||
self.assertEqual(v["display_name"], "Model Y")
|
||||
|
||||
def test_select_vehicle_partial_substring(self):
|
||||
v = tesla._select_vehicle(self.vehicles, "road")
|
||||
self.assertEqual(v["display_name"], "Road Trip")
|
||||
|
||||
def test_select_vehicle_index_1_based(self):
|
||||
v = tesla._select_vehicle(self.vehicles, "2")
|
||||
self.assertEqual(v["display_name"], "Road Trip")
|
||||
|
||||
def test_select_vehicle_ambiguous_returns_none(self):
|
||||
vehicles = [
|
||||
{"display_name": "Alpha"},
|
||||
{"display_name": "Alphanumeric"},
|
||||
]
|
||||
self.assertIsNone(tesla._select_vehicle(vehicles, "alp"))
|
||||
|
||||
def test_get_vehicle_ambiguous_error_is_helpful(self):
|
||||
class FakeTesla:
|
||||
def vehicle_list(self_inner):
|
||||
return [
|
||||
{"display_name": "Alpha"},
|
||||
{"display_name": "Alphanumeric"},
|
||||
{"display_name": "Beta"},
|
||||
]
|
||||
|
||||
# Force ambiguity and assert we mention that it's ambiguous + show index hint.
|
||||
stderr = sys.stderr
|
||||
try:
|
||||
from io import StringIO
|
||||
|
||||
buf = StringIO()
|
||||
sys.stderr = buf
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
tesla.get_vehicle(FakeTesla(), name="alp")
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
out = buf.getvalue()
|
||||
self.assertIn("ambiguous", out.lower())
|
||||
self.assertIn("--car <N>", out)
|
||||
self.assertIn("Matches:", out)
|
||||
finally:
|
||||
sys.stderr = stderr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user