From d8a1c893b88b1284366b79aa3acc8e1a7ee130d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=2E=20L=C3=B3pez?= Date: Sun, 8 Dec 2024 18:45:09 +0100 Subject: [PATCH 1/6] Add sport details to database (steps, calories and distance) --- colmi_r02_client/client.py | 14 +++++++++---- colmi_r02_client/db.py | 40 +++++++++++++++++++++++++++++++++++++- colmi_r02_client/steps.py | 11 ++++++++++- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/colmi_r02_client/client.py b/colmi_r02_client/client.py index 864d577..8492fc8 100644 --- a/colmi_r02_client/client.py +++ b/colmi_r02_client/client.py @@ -37,6 +37,7 @@ def log_packet(packet: bytearray) -> None: class FullData: address: str heart_rates: list[hr.HeartRateLog | hr.NoData] + sport_details: list[steps.SportDetail | hr.NoData] COMMAND_HANDLERS: dict[int, Callable[[bytearray], Any]] = { @@ -249,9 +250,14 @@ class Client: """ Fetches all data from the ring between start and end. Useful for syncing. """ - - logs = [] + heart_rate_logs = [] + sport_detail_logs = [] for d in date_utils.dates_between(start, end): - logs.append(await self.get_heart_rate_log(d)) + heart_rate_logs.append(await self.get_heart_rate_log(d)) + sport_details = await self.get_steps(d) + # In some circumstances Client.get_steps returns NoData, just wrap it into a list + if not isinstance(sport_details, list): + sport_details = [sport_details] # type: ignore[list-item] + sport_detail_logs.extend(sport_details) - return FullData(self.address, logs) + return FullData(self.address, heart_rates=heart_rate_logs, sport_details=sport_detail_logs) diff --git a/colmi_r02_client/db.py b/colmi_r02_client/db.py index 21b144b..04443df 100644 --- a/colmi_r02_client/db.py +++ b/colmi_r02_client/db.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, rela from sqlalchemy import select, UniqueConstraint, ForeignKey, create_engine, event, func, types from sqlalchemy.engine import Engine, Dialect -from colmi_r02_client import hr +from colmi_r02_client import hr, steps from colmi_r02_client.client import FullData from colmi_r02_client.date_utils import start_of_day, end_of_day @@ -61,6 +61,7 @@ class Ring(Base): ring_id: Mapped[int] = mapped_column(primary_key=True) address: Mapped[str] heart_rates: Mapped[list["HeartRate"]] = relationship(back_populates="ring") + sport_details: Mapped[list["SportDetail"]] = relationship(back_populates="ring") syncs: Mapped[list["Sync"]] = relationship(back_populates="ring") @@ -72,6 +73,7 @@ class Sync(Base): comment: Mapped[str | None] ring: Mapped["Ring"] = relationship(back_populates="syncs") heart_rates: Mapped[list["HeartRate"]] = relationship(back_populates="sync") + sport_details: Mapped[list["SportDetail"]] = relationship(back_populates="sync") class HeartRate(Base): @@ -86,6 +88,20 @@ class HeartRate(Base): sync: Mapped["Sync"] = relationship(back_populates="heart_rates") +class SportDetail(Base): + __tablename__ = "sport_details" + __table_args__ = (UniqueConstraint("ring_id", "timestamp"),) + sport_detail_id: Mapped[int] = mapped_column(primary_key=True) + calories: Mapped[int] + steps: Mapped[int] + distance: Mapped[int] + timestamp = mapped_column(DateTimeInUTC(timezone=True), nullable=False) + ring_id = mapped_column(ForeignKey("rings.ring_id"), nullable=False) + ring: Mapped["Ring"] = relationship(back_populates="sport_details") + sync_id = mapped_column(ForeignKey("syncs.sync_id"), nullable=False) + sync: Mapped["Sync"] = relationship(back_populates="sport_details") + + @event.listens_for(Engine, "connect") def set_sqlite_pragma(dbapi_connection: Any, _connection_record: Any) -> None: """Enable actual foreign key checks in sqlite on every connection to the database""" @@ -159,6 +175,28 @@ def sync(session: Session, data: FullData) -> None: h = HeartRate(reading=reading, timestamp=timestamp, ring=ring, sync=sync) session.add(h) + sport_detail_logs = [x for x in data.sport_details if isinstance(x, steps.SportDetail)] + start = min([sport_detail.timestamp for sport_detail in sport_detail_logs]) + end = max([sport_detail.timestamp for sport_detail in sport_detail_logs]) + + existing = {} + for sport_detail in session.scalars( + select(SportDetail) + .where(SportDetail.timestamp >= start_of_day(start)) + .where(SportDetail.timestamp <= end_of_day(end)) + ): + existing[sport_detail.timestamp] = sport_detail + + for sport_detail in sport_detail_logs: + if x := existing.get(sport_detail.timestamp): + x.calories = sport_detail.calories + x.steps = sport_detail.steps + x.distance = sport_detail.distance + session.add(x) + else: + s = SportDetail(calories=sport_detail.calories, steps=sport_detail.steps, distance=sport_detail.distance, timestamp=sport_detail.timestamp, ring=ring, sync=sync) + session.add(s) + session.commit() diff --git a/colmi_r02_client/steps.py b/colmi_r02_client/steps.py index d0a20b5..6caeb23 100644 --- a/colmi_r02_client/steps.py +++ b/colmi_r02_client/steps.py @@ -1,4 +1,8 @@ from dataclasses import dataclass +from datetime import datetime +import zoneinfo + +from asyncclick import DateTime from colmi_r02_client.packet import make_packet @@ -27,12 +31,17 @@ class SportDetail: month: int day: int time_index: int - """I'm not sure about this one yet""" + """time_index represents 15 minutes intevals within a day""" calories: int steps: int distance: int """Distance in meters""" + @property + def timestamp(self) -> datetime: + # time_index * 15 + return datetime(year=self.year, month=self.month, day=self.day, hour=self.time_index // 4, minute=self.time_index % 4 * 15, tzinfo=zoneinfo.ZoneInfo("UTC")) + class NoData: """Returned when there's no heart rate data""" From 200047020272cdbb5625b4fd2f120a9c24b33451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20L=C3=B3pez?= Date: Mon, 9 Dec 2024 10:22:10 +0100 Subject: [PATCH 2/6] Clean up import and comments - Remove unnecessary import added by mistake - Fix comments about how to handle SportDetail.time_index --- colmi_r02_client/steps.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/colmi_r02_client/steps.py b/colmi_r02_client/steps.py index 6caeb23..b816249 100644 --- a/colmi_r02_client/steps.py +++ b/colmi_r02_client/steps.py @@ -2,8 +2,6 @@ from dataclasses import dataclass from datetime import datetime import zoneinfo -from asyncclick import DateTime - from colmi_r02_client.packet import make_packet CMD_GET_STEP_SOMEDAY = 67 # 0x43 @@ -39,8 +37,16 @@ class SportDetail: @property def timestamp(self) -> datetime: - # time_index * 15 - return datetime(year=self.year, month=self.month, day=self.day, hour=self.time_index // 4, minute=self.time_index % 4 * 15, tzinfo=zoneinfo.ZoneInfo("UTC")) + # Move this to date_utils? + # convert time_index into a timedelta to add to base year, month, day. + return datetime( + year=self.year, + month=self.month, + day=self.day, + hour=self.time_index // 4, + minute=self.time_index % 4 * 15, + tzinfo=zoneinfo.ZoneInfo("UTC"), + ) class NoData: From 5804f67160b48fa62e7736468ecec66a8ec8fe8e Mon Sep 17 00:00:00 2001 From: Wesley Ellis Date: Fri, 3 Jan 2025 22:29:15 -0500 Subject: [PATCH 3/6] test: disable slow hypothesis tests --- tests/test_db.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_db.py b/tests/test_db.py index 379060c..3e90bee 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -261,6 +261,7 @@ def test_datetime_in_utc_process_bind_none(): assert dtiu.process_bind_param(None, dialect) is None +@pytest.mark.skip @given(st.datetimes()) def test_datetime_in_utc_process_bind_no_tz(ts: datetime): dtiu = DateTimeInUTC() @@ -270,6 +271,7 @@ def test_datetime_in_utc_process_bind_no_tz(ts: datetime): dtiu.process_bind_param(ts, dialect) +@pytest.mark.skip @given(st.datetimes(timezones=st.timezones())) def test_datetime_in_utc_process_bind_tz(ts: datetime): dtiu = DateTimeInUTC() @@ -289,6 +291,7 @@ def test_datetime_in_utc_process_result_none(): assert dtiu.process_result_value(None, dialect) is None +@pytest.mark.skip @given(st.datetimes()) def test_datetime_in_utc_process_result_no_tz(ts: datetime): dtiu = DateTimeInUTC() @@ -300,6 +303,7 @@ def test_datetime_in_utc_process_result_no_tz(ts: datetime): assert result.tzinfo == timezone.utc +@pytest.mark.skip @given(st.datetimes(timezones=st.timezones())) def test_datetime_in_utc_process_tz(ts: datetime): dtiu = DateTimeInUTC() From fc5d6136bf8faadb01d6183c2de64c37ea2190d9 Mon Sep 17 00:00:00 2001 From: Wesley Ellis Date: Fri, 3 Jan 2025 22:29:42 -0500 Subject: [PATCH 4/6] chore: disable annoying ruff check --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e4b6bf1..8224ea0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,3 +76,6 @@ select = [ "YTT", # flake8-2020 "RUF", # Ruff-specific rules ] +ignore = [ + "SIM108", +] From a5c69ffb92f6e1db58d785cacbb4b12d9d2f1dfb Mon Sep 17 00:00:00 2001 From: Wesley Ellis Date: Fri, 3 Jan 2025 22:30:55 -0500 Subject: [PATCH 5/6] fix: make click cli args datetime aware --- colmi_r02_client/cli.py | 8 +++++++- colmi_r02_client/date_utils.py | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/colmi_r02_client/cli.py b/colmi_r02_client/cli.py index 8078e2e..df58c43 100644 --- a/colmi_r02_client/cli.py +++ b/colmi_r02_client/cli.py @@ -105,7 +105,7 @@ async def set_time(client: Client, when: datetime | None) -> None: """ if when is None: - when = datetime.now(tz=timezone.utc) + when = date_utils.now() async with client: await client.set_time(when) click.echo("Time set successfully") @@ -258,10 +258,16 @@ async def sync(client: Client, db_path: Path | None, start: datetime | None, end with db.get_db_session(db_path) as session: if start is None: start = db.get_last_sync(session) + else: + start = date_utils.naive_to_aware(start) + if start is None: start = date_utils.now() - timedelta(days=7) + if end is None: end = date_utils.now() + else: + end = date_utils.naive_to_aware(end) click.echo(f"Syncing from {start} to {end}") diff --git a/colmi_r02_client/date_utils.py b/colmi_r02_client/date_utils.py index 6e3585d..d106ea1 100644 --- a/colmi_r02_client/date_utils.py +++ b/colmi_r02_client/date_utils.py @@ -39,3 +39,10 @@ def minutes_so_far(dt: datetime) -> int: def is_today(ts: datetime) -> bool: n = now() return bool(ts.year == n.year and ts.month == n.month and ts.day == n.day) + + +def naive_to_aware(dt: datetime) -> datetime: + if dt.tzinfo is not None: + raise ValueError("already tz aware datetime") + + return dt.replace(tzinfo=timezone.utc) From e4a3b36fd762351e18047a5fa240d844d19f116e Mon Sep 17 00:00:00 2001 From: Wesley Ellis Date: Fri, 3 Jan 2025 22:31:36 -0500 Subject: [PATCH 6/6] feat: finish syncing steps / sport details and add tests --- colmi_r02_client/cli.py | 2 +- colmi_r02_client/client.py | 8 +--- colmi_r02_client/db.py | 41 ++++++++++++---- colmi_r02_client/steps.py | 7 +-- tests/database_schema.sql | 14 ++++++ tests/test_db.py | 95 +++++++++++++++++++++++++++----------- tests/test_steps.py | 30 ++++++++++++ 7 files changed, 148 insertions(+), 49 deletions(-) diff --git a/colmi_r02_client/cli.py b/colmi_r02_client/cli.py index df58c43..581a1f8 100644 --- a/colmi_r02_client/cli.py +++ b/colmi_r02_client/cli.py @@ -273,7 +273,7 @@ async def sync(client: Client, db_path: Path | None, start: datetime | None, end async with client: fd = await client.get_full_data(start, end) - db.sync(session, fd) + db.full_sync(session, fd) when = datetime.now(tz=timezone.utc) click.echo("Ignore unexpect packet") await client.set_time(when) diff --git a/colmi_r02_client/client.py b/colmi_r02_client/client.py index 8492fc8..3f74e2e 100644 --- a/colmi_r02_client/client.py +++ b/colmi_r02_client/client.py @@ -37,7 +37,7 @@ def log_packet(packet: bytearray) -> None: class FullData: address: str heart_rates: list[hr.HeartRateLog | hr.NoData] - sport_details: list[steps.SportDetail | hr.NoData] + sport_details: list[list[steps.SportDetail] | steps.NoData] COMMAND_HANDLERS: dict[int, Callable[[bytearray], Any]] = { @@ -254,10 +254,6 @@ class Client: sport_detail_logs = [] for d in date_utils.dates_between(start, end): heart_rate_logs.append(await self.get_heart_rate_log(d)) - sport_details = await self.get_steps(d) - # In some circumstances Client.get_steps returns NoData, just wrap it into a list - if not isinstance(sport_details, list): - sport_details = [sport_details] # type: ignore[list-item] - sport_detail_logs.extend(sport_details) + sport_detail_logs.append(await self.get_steps(d)) return FullData(self.address, heart_rates=heart_rate_logs, sport_details=sport_detail_logs) diff --git a/colmi_r02_client/db.py b/colmi_r02_client/db.py index 04443df..239d700 100644 --- a/colmi_r02_client/db.py +++ b/colmi_r02_client/db.py @@ -140,17 +140,23 @@ def create_or_find_ring(session: Session, address: str) -> Ring: return ring -def sync(session: Session, data: FullData) -> None: +def full_sync(session: Session, data: FullData) -> None: """ TODO: - grab battery - - grab steps """ ring = create_or_find_ring(session, data.address) sync = Sync(ring=ring, timestamp=datetime.now(tz=timezone.utc)) session.add(sync) + _add_heart_rate(sync, ring, data, session) + _add_sport_details(sync, ring, data, session) + session.commit() + + +def _add_heart_rate(sync: Sync, ring: Ring, data: FullData, session: Session) -> None: + logger.info(f"Adding {len(data.heart_rates)} days of heart rates") for log in data.heart_rates: if isinstance(log, hr.NoData): logger.info("No heart rate data for date") @@ -175,30 +181,45 @@ def sync(session: Session, data: FullData) -> None: h = HeartRate(reading=reading, timestamp=timestamp, ring=ring, sync=sync) session.add(h) - sport_detail_logs = [x for x in data.sport_details if isinstance(x, steps.SportDetail)] + +def _add_sport_details(sync: Sync, ring: Ring, data: FullData, session: Session) -> None: + logger.info(f"Adding {len(data.sport_details)} days of sport details") + sport_detail_logs: list[steps.SportDetail] = [] + for slog in data.sport_details: + if isinstance(slog, steps.NoData): + logger.info("No step data for date") + else: + sport_detail_logs.extend(slog) + if len(sport_detail_logs) == 0: + return start = min([sport_detail.timestamp for sport_detail in sport_detail_logs]) end = max([sport_detail.timestamp for sport_detail in sport_detail_logs]) - existing = {} - for sport_detail in session.scalars( + existing_sport_logs = {} + for sport_detail_record in session.scalars( select(SportDetail) .where(SportDetail.timestamp >= start_of_day(start)) .where(SportDetail.timestamp <= end_of_day(end)) ): - existing[sport_detail.timestamp] = sport_detail + existing_sport_logs[sport_detail_record.timestamp] = sport_detail_record for sport_detail in sport_detail_logs: - if x := existing.get(sport_detail.timestamp): + if x := existing_sport_logs.get(sport_detail.timestamp): x.calories = sport_detail.calories x.steps = sport_detail.steps x.distance = sport_detail.distance session.add(x) else: - s = SportDetail(calories=sport_detail.calories, steps=sport_detail.steps, distance=sport_detail.distance, timestamp=sport_detail.timestamp, ring=ring, sync=sync) + s = SportDetail( + calories=sport_detail.calories, + steps=sport_detail.steps, + distance=sport_detail.distance, + timestamp=sport_detail.timestamp, + ring=ring, + sync=sync, + ) session.add(s) - session.commit() - def get_last_sync(session: Session) -> datetime | None: return session.scalars(func.max(Sync.timestamp)).one_or_none() diff --git a/colmi_r02_client/steps.py b/colmi_r02_client/steps.py index b816249..42bcd2c 100644 --- a/colmi_r02_client/steps.py +++ b/colmi_r02_client/steps.py @@ -1,6 +1,5 @@ from dataclasses import dataclass -from datetime import datetime -import zoneinfo +from datetime import datetime, timezone from colmi_r02_client.packet import make_packet @@ -37,15 +36,13 @@ class SportDetail: @property def timestamp(self) -> datetime: - # Move this to date_utils? - # convert time_index into a timedelta to add to base year, month, day. return datetime( year=self.year, month=self.month, day=self.day, hour=self.time_index // 4, minute=self.time_index % 4 * 15, - tzinfo=zoneinfo.ZoneInfo("UTC"), + tzinfo=timezone.utc, ) diff --git a/tests/database_schema.sql b/tests/database_schema.sql index 1c32b95..59abdaa 100644 --- a/tests/database_schema.sql +++ b/tests/database_schema.sql @@ -24,4 +24,18 @@ CREATE TABLE heart_rates ( UNIQUE (ring_id, timestamp), FOREIGN KEY(ring_id) REFERENCES rings (ring_id), FOREIGN KEY(sync_id) REFERENCES syncs (sync_id) +) + +CREATE TABLE sport_details ( + sport_detail_id INTEGER NOT NULL, + calories INTEGER NOT NULL, + steps INTEGER NOT NULL, + distance INTEGER NOT NULL, + timestamp DATETIME NOT NULL, + ring_id INTEGER NOT NULL, + sync_id INTEGER NOT NULL, + PRIMARY KEY (sport_detail_id), + UNIQUE (ring_id, timestamp), + FOREIGN KEY(ring_id) REFERENCES rings (ring_id), + FOREIGN KEY(sync_id) REFERENCES syncs (sync_id) ) \ No newline at end of file diff --git a/tests/test_db.py b/tests/test_db.py index 3e90bee..6b01d2a 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -9,19 +9,30 @@ from sqlalchemy import text, select, func, Dialect from sqlalchemy.exc import IntegrityError from colmi_r02_client.client import FullData -from colmi_r02_client import hr +from colmi_r02_client import hr, steps from colmi_r02_client.db import ( get_db_session, create_or_find_ring, - sync, + full_sync, Ring, HeartRate, + SportDetail, Sync, get_last_sync, DateTimeInUTC, ) +@pytest.fixture(name="address") +def get_address() -> str: + return "fake" + + +@pytest.fixture(name="empty_full_data") +def get_empty_full_data(address) -> FullData: + return FullData(address=address, heart_rates=[], sport_details=[]) + + def test_get_db_session_memory(): with get_db_session() as session: assert session.scalars(text("SELECT 1")).one() == 1 @@ -44,6 +55,7 @@ def test_get_db_tables_exist(): "rings", "syncs", "heart_rates", + "sport_details", } @@ -86,32 +98,25 @@ def test_ring_sync_id_required_for_heart_rate(): session.commit() -def test_sync_creates_ring(): - address = "fake" - fd = FullData(address=address, heart_rates=[]) +def test_sync_creates_ring(address, empty_full_data): with get_db_session() as session: - sync(session, fd) + full_sync(session, empty_full_data) ring = session.scalars(select(Ring)).one() assert address == ring.address -def test_sync_uses_existing_ring(): - address = "fake" - fd = FullData(address=address, heart_rates=[]) - +def test_sync_uses_existing_ring(address, empty_full_data): with get_db_session() as session: create_or_find_ring(session, address) - sync(session, fd) + full_sync(session, empty_full_data) assert session.scalars(func.count(Ring.ring_id)).one() == 1 -def test_sync_creates_sync(): - address = "fake" - fd = FullData(address=address, heart_rates=[]) +def test_sync_creates_sync(address, empty_full_data): with get_db_session() as session: - sync(session, fd) + full_sync(session, empty_full_data) sync_obj = session.scalars(select(Sync)).one() @@ -127,9 +132,9 @@ def test_sync_writes_heart_rates(): index=295, range=5, ) - fd = FullData(address=address, heart_rates=[hrl]) + fd = FullData(address=address, heart_rates=[hrl], sport_details=[]) with get_db_session() as session: - sync(session, fd) + full_sync(session, fd) ring = session.scalars(select(Ring)).one() logs = session.scalars(select(HeartRate)).all() @@ -152,9 +157,9 @@ def test_sync_writes_heart_rates_only_non_zero_heart_rates(): index=295, range=5, ) - fd = FullData(address=address, heart_rates=[hrl]) + fd = FullData(address=address, heart_rates=[hrl], sport_details=[]) with get_db_session() as session: - sync(session, fd) + full_sync(session, fd) logs = session.scalars(select(HeartRate)).all() @@ -170,7 +175,7 @@ def test_sync_writes_heart_rates_once(): index=295, range=5, ) - fd_1 = FullData(address=address, heart_rates=[hrl_1]) + fd_1 = FullData(address=address, heart_rates=[hrl_1], sport_details=[]) hrl_2 = hr.HeartRateLog( heart_rates=[80] * 288, @@ -179,10 +184,10 @@ def test_sync_writes_heart_rates_once(): index=295, range=5, ) - fd_2 = FullData(address=address, heart_rates=[hrl_2]) + fd_2 = FullData(address=address, heart_rates=[hrl_2], sport_details=[]) with get_db_session() as session: - sync(session, fd_1) - sync(session, fd_2) + full_sync(session, fd_1) + full_sync(session, fd_2) logs = session.scalars(select(HeartRate)).all() @@ -198,7 +203,7 @@ def test_sync_handles_inconsistent_data(caplog): index=295, range=5, ) - fd_1 = FullData(address=address, heart_rates=[hrl_1]) + fd_1 = FullData(address=address, heart_rates=[hrl_1], sport_details=[]) hrl_2 = hr.HeartRateLog( heart_rates=[90] * 288, @@ -207,10 +212,10 @@ def test_sync_handles_inconsistent_data(caplog): index=295, range=5, ) - fd_2 = FullData(address=address, heart_rates=[hrl_2]) + fd_2 = FullData(address=address, heart_rates=[hrl_2], sport_details=[]) with get_db_session() as session: - sync(session, fd_1) - sync(session, fd_2) + full_sync(session, fd_1) + full_sync(session, fd_2) logs = session.scalars(select(HeartRate)).all() @@ -219,6 +224,42 @@ def test_sync_handles_inconsistent_data(caplog): assert "Inconsistent data detected! 2024-11-11 00:00:00+00:00 is 80 in db but got 90 from ring" in caplog.text +def test_full_sync_writes_sport_details(): + address = "fake" + sd = steps.SportDetail( + year=2025, + month=1, + day=1, + time_index=0, + calories=4200, + steps=6969, + distance=1234, + ) + fd = FullData(address=address, heart_rates=[], sport_details=[[sd]]) + with get_db_session() as session: + full_sync(session, fd) + + ring = session.scalars(select(Ring)).one() + sport_details = session.scalars(select(SportDetail)).all() + sync_obj = session.scalars(select(Sync)).one() + + assert len(sport_details) == 1 + assert sport_details[0].ring_id == ring.ring_id + assert sport_details[0].timestamp == datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc) + assert sport_details[0].sync_id == sync_obj.sync_id + + +def test_full_sync_no_sport_details(): + address = "fake" + fd = FullData(address=address, heart_rates=[], sport_details=[steps.NoData(), steps.NoData()]) + with get_db_session() as session: + full_sync(session, fd) + + sport_details = session.scalars(select(SportDetail)).all() + + assert len(sport_details) == 0 + + def test_get_last_sync_never(): with get_db_session() as session: assert get_last_sync(session) is None diff --git a/tests/test_steps.py b/tests/test_steps.py index 8f10667..b8eb299 100644 --- a/tests/test_steps.py +++ b/tests/test_steps.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + from colmi_r02_client.steps import SportDetailParser, SportDetail, NoData @@ -86,3 +88,31 @@ def test_no_data_parse(): actual = sdp.parse(resp) assert isinstance(actual, NoData) + + +def test_timestamp_midnight(): + sd = SportDetail( + year=2025, + month=1, + day=1, + time_index=0, + calories=0, + distance=0, + steps=0, + ) + ts = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc) + assert sd.timestamp == ts + + +def test_timestamp_one_more(): + sd = SportDetail( + year=2025, + month=1, + day=1, + time_index=95, + calories=0, + distance=0, + steps=0, + ) + ts = datetime(2025, 1, 1, 23, 45, tzinfo=timezone.utc) + assert sd.timestamp == ts