From 8049c0e005c0087ea8fe0b3a50d488cf598f6d22 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Tue, 30 Jul 2024 09:12:06 +0200 Subject: [PATCH 1/5] Factor out test fixture --- tests/test_heat_it.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_heat_it.py b/tests/test_heat_it.py index cdc0fab..f03a0cb 100644 --- a/tests/test_heat_it.py +++ b/tests/test_heat_it.py @@ -29,18 +29,22 @@ def fixture_find_dummy_device( return lambda: iter((dummy_device,)) -def test_default( +@pytest.fixture(name='bulk_transfer') +def fixture_bulk_transfer( + mocker: pytest_mock.MockerFixture, monkeypatch: pytest.MonkeyPatch, find_dummy_device: Iterator[AbstractContextManager[Device]], - mocker: pytest_mock.MockerFixture, -) -> None: +) -> Iterator[pytest_mock.MockType]: monkeypatch.setattr(devices, 'find_devices', find_dummy_device) - bulk_transfer = mocker.spy( _DummyUsbBulkTransferDevice, 'bulk_transfer' ) - Api().start() + yield bulk_transfer assert bulk_transfer.call_count == 3 + + +def test_default(bulk_transfer: pytest_mock.MockType) -> None: + Api().start() bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x00, 0x00, 0x08] ) From 056dc8d35d0bf612e4f00228efba4e20b7548607 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Tue, 30 Jul 2024 10:00:27 +0200 Subject: [PATCH 2/5] VS Code: enable format-on-save --- .vscode/settings.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 1e46ca3..96fa01b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,10 +6,12 @@ "editor.defaultFormatter": "vscode.json-language-features" }, "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff" + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true }, "[toml]": { - "editor.defaultFormatter": "tamasfe.even-better-toml" + "editor.defaultFormatter": "tamasfe.even-better-toml", + "editor.formatOnSave": true }, "code-runner.clearPreviousOutput": true, "code-runner.executorMap": { From 328aeefb2b74182f376eb519de951a61d8251492 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Tue, 30 Jul 2024 11:58:58 +0200 Subject: [PATCH 3/5] Introduce user preferences --- itchcraft/api.py | 49 ++++++++++++++++++- itchcraft/prefs.py | 110 ++++++++++++++++++++++++++++++++++++++++++ tests/test_heat_it.py | 46 +++++++++++++++++- 3 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 itchcraft/prefs.py diff --git a/itchcraft/api.py b/itchcraft/api.py index c77c270..d18e4ff 100644 --- a/itchcraft/api.py +++ b/itchcraft/api.py @@ -1,23 +1,67 @@ """The primary module in itchcraft.""" from contextlib import ExitStack +from dataclasses import dataclass -from . import devices +from . import devices, prefs from .errors import CliError from .logging import get_logger +from .prefs import ( + CliEnum, + Duration, + Generation, + Preferences, + SkinSensitivity, +) logger = get_logger(__name__) +@dataclass(frozen=True) +class StartParams: + """Parameters for the `start` method or CLI subcommand.""" + + duration: CliEnum[Duration] = prefs.default(Duration) + generation: CliEnum[Generation] = prefs.default(Generation) + skin_sensitivity: CliEnum[SkinSensitivity] = prefs.default( + SkinSensitivity + ) + + # pylint: disable=too-few-public-methods class Api: """Tech demo for interfacing with heat-based USB insect bite healers""" - def start(self) -> None: # pylint: disable=no-self-use + # pylint: disable=no-self-use + def start( + self, + # Re-enumerating all the `StartParams` fields to make Fire happy + duration: CliEnum[Duration] = prefs.default(Duration), + generation: CliEnum[Generation] = prefs.default(Generation), + skin_sensitivity: CliEnum[SkinSensitivity] = prefs.default( + SkinSensitivity + ), + ) -> None: # pylint: disable=no-self-use """Activates (i.e. heats up) a connected USB bite healer for demonstration purposes. + + :param duration: + One of `short`, `medium`, or `long`. + + :param generation: + `child` or `adult`. + + :param skin_sensitivity: + `regular` or `sensitive`. """ + preferences = Preferences( + duration=prefs.parse(duration, Duration), + generation=prefs.parse(generation, Generation), + skin_sensitivity=prefs.parse( + skin_sensitivity, SkinSensitivity + ), + ) logger.warning('This app is only a tech demo') logger.warning('and NOT for medical use.') logger.warning('The app is NOT SAFE to use') @@ -43,5 +87,6 @@ def start(self) -> None: # pylint: disable=no-self-use 'Itchcraft can only use one device at a time.' ) + logger.info('Using settings: %s', preferences) device.self_test() device.start_heating() diff --git a/itchcraft/prefs.py b/itchcraft/prefs.py new file mode 100644 index 0000000..961f316 --- /dev/null +++ b/itchcraft/prefs.py @@ -0,0 +1,110 @@ +"""User preferences""" + +from dataclasses import dataclass, field +from enum import Enum +import re +from typing import TypeVar, Union + +from .errors import CliError + +E = TypeVar('E', bound=Enum) + +# If a CLI switch is backed by an enum, then allow the enum to stand in +# for that switch +CliEnum = Union[str, E] + + +class SkinSensitivity(Enum): + """Whether or not a person’s skin is particularly sensitive.""" + + SENSITIVE = 1 + REGULAR = 2 + + def __str__(self) -> str: + return f'{self.name.lower()} skin' + + +class Generation(Enum): + """The age cohort of a person.""" + + CHILD = 1 + ADULT = 2 + + def __str__(self) -> str: + return self.name.lower() + + +class Duration(Enum): + """The duration of a demo session.""" + + SHORT = 1 + MEDIUM = 2 + LONG = 3 + + def __str__(self) -> str: + return f'{self.name.lower()} duration' + + +@dataclass(frozen=True) +class Preferences: + """User preferences for a bite healer demo session.""" + + skin_sensitivity: SkinSensitivity = field( + default=SkinSensitivity.SENSITIVE + ) + generation: Generation = field(default=Generation.CHILD) + duration: Duration = field(default=Duration.SHORT) + + def __str__(self) -> str: + return ', '.join( + str(attr) + for attr in ( + self.duration, + self.generation, + self.skin_sensitivity, + ) + ) + + +def default(enum_type: type[E]) -> str: + """Returns the default preference for a given Enum type. + + :param enum_type: + Enum type which exists as an attribute in Preferences and + whose corresponding attribute name is equal to the type name + converted to snake case. + """ + default_value: E = getattr(Preferences, _snake_case_name(enum_type)) + return default_value.name.lower() + + +# pylint: disable=raise-missing-from +def parse(value: CliEnum[E], enum_type: type[E]) -> E: + """Parses a given value into an Enum if it isn’t one yet. + Returns the value itself if it’s already an Enum. + + :param value: + an Enum value or a corresponding name, written in lower case. + + :param enum_type: + the type of the Enum to parse into. + """ + if isinstance(value, enum_type): + return value + assert isinstance(value, str) + try: + return enum_type[value.upper()] + except KeyError: + raise CliError( + f'Invalid value `{value}`. Valid values for {_snake_case_name(enum_type)} are: ' + + ', '.join([key.lower() for key in enum_type.__members__]), + ) + + +def _snake_case_name(camel_case_type: type) -> str: + """Returns the name of the given type converted into snake case.""" + return re.sub( + r'(?:\B|\Z)([A-Z])', + lambda match: f'_{match.group(1)}', + camel_case_type.__name__, + ).lower() diff --git a/tests/test_heat_it.py b/tests/test_heat_it.py index f03a0cb..6fea1cc 100644 --- a/tests/test_heat_it.py +++ b/tests/test_heat_it.py @@ -5,6 +5,7 @@ AbstractContextManager, nullcontext, ) +from dataclasses import asdict from typing import Optional, Union from unittest.mock import ANY @@ -12,9 +13,11 @@ import pytest_mock from itchcraft import Api, devices +from itchcraft.api import StartParams from itchcraft.backend import BulkTransferDevice from itchcraft.device import Device from itchcraft.heat_it import HeatItDevice +from itchcraft.prefs import Duration, Generation, SkinSensitivity @pytest.fixture(name='dummy_device') @@ -43,13 +46,54 @@ def fixture_bulk_transfer( assert bulk_transfer.call_count == 3 -def test_default(bulk_transfer: pytest_mock.MockType) -> None: +def test_no_preferences(bulk_transfer: pytest_mock.MockType) -> None: Api().start() bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x00, 0x00, 0x08] ) +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(), + StartParams(duration='short'), + StartParams(duration=Duration.SHORT), + StartParams(duration='short', generation='child'), + StartParams( + duration=Duration.SHORT, generation=Generation.CHILD + ), + StartParams(generation='child'), + StartParams(generation=Generation.CHILD), + StartParams(skin_sensitivity='sensitive'), + StartParams(skin_sensitivity=SkinSensitivity.SENSITIVE), + StartParams( + duration='short', + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_default_preferences( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, [0xFF, 0x08, 0x00, 0x00, 0x08] + ) + + class _DummyUsbBulkTransferDevice(BulkTransferDevice): def bulk_transfer( self, request: Union[list[int], bytes, bytearray] From 6123477e03025795a03a02c72c9d38ca66d347df Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Tue, 30 Jul 2024 13:09:10 +0200 Subject: [PATCH 4/5] Add settings for skin sensitivity, age, duration --- README.md | 16 +- USAGE.md | 28 +++ itchcraft/api.py | 2 +- itchcraft/device.py | 5 +- itchcraft/heat_it.py | 38 +++- tests/test_heat_it.py | 470 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 545 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d94a358..58a3f1a 100644 --- a/README.md +++ b/README.md @@ -135,14 +135,18 @@ interesting to you for those purposes. ## Features -At the moment, Itchcraft offers only a single feature: +At the moment, Itchcraft offers the following features: -- Activate your insect bite healer with the lowest and safest setting: - the setting for children with sensitive skin and the shortest possible - duration. +- Activate a technical demonstration of your insect bite healer + using the command line -More features, including a graphical front-end and stronger settings, -are planned. +- Choose a duration: short, medium, or long + +- Choose a generation: child or adult + +- Choose a skin sensitivity level: regular skin or sensitive skin + +A graphical front-end is planned. ## System requirements diff --git a/USAGE.md b/USAGE.md index f2d0073..be267ad 100644 --- a/USAGE.md +++ b/USAGE.md @@ -22,6 +22,34 @@ itchcraft COMMAND : Activates (i.e. heats up) a connected USB bite healer for : demonstration purposes. +# Flags + +The `start` command supports the following flags: + +## `-d`, `--duration=DURATION` + +The duration of the demonstration. + +One of `short`, `medium`, or `long`. + +The default is `short`, the safest setting. + +## `-g`, `--generation=GENERATION` + +Whether the demonstration corresponds to treating an adult or a child. + +One of the values `child` or `adult`. + +The default is `child`, the safer setting of the two. + +## `-s`, `--skin_sensitivity=SKIN_SENSITIVITY` + +Whether the tech demo caters to regular or particularly sensitive skin. + +One of the values `regular` or `sensitive`. + +The default is `sensitive`, the safer setting of the two. + # Monitoring the bite healer’s state once activated ## Monitoring the state by observing the LED color (recommended) diff --git a/itchcraft/api.py b/itchcraft/api.py index d18e4ff..9363b4e 100644 --- a/itchcraft/api.py +++ b/itchcraft/api.py @@ -89,4 +89,4 @@ def start( logger.info('Using settings: %s', preferences) device.self_test() - device.start_heating() + device.start_heating(preferences) diff --git a/itchcraft/device.py b/itchcraft/device.py index c52aea2..c59eef3 100644 --- a/itchcraft/device.py +++ b/itchcraft/device.py @@ -2,6 +2,9 @@ from abc import ABC, abstractmethod +from .prefs import Preferences + + class Device(ABC): """Abstraction for a bite healer.""" @@ -11,5 +14,5 @@ def self_test(self) -> None: functional.""" @abstractmethod - def start_heating(self) -> None: + def start_heating(self, preferences: Preferences) -> None: """Tells the device to start heating up.""" diff --git a/itchcraft/heat_it.py b/itchcraft/heat_it.py index bc81c80..a24df9e 100644 --- a/itchcraft/heat_it.py +++ b/itchcraft/heat_it.py @@ -1,5 +1,7 @@ """Backend for heat-it""" +from collections.abc import Iterable +from functools import reduce from typing import Optional, Union from tenacity import ( @@ -13,6 +15,7 @@ from .backend import BulkTransferDevice from .device import Device from .logging import get_logger +from .prefs import Preferences RESPONSE_LENGTH = 12 @@ -38,12 +41,37 @@ def get_status(self) -> bytes: """Issues a `GET_STATUS` command and returns the response.""" return self._command([0xFF, 0x02, 0x02], 'GET_STATUS') - def msg_start_heating(self) -> bytes: + def msg_start_heating(self, preferences: Preferences) -> bytes: """Issues a `MSG_START_HEATING` command and returns the response. """ + + def duration_code() -> int: + return preferences.duration.value - 1 + + def generation_code() -> int: + return preferences.generation.value - 1 + + def skin_sensitivity_code() -> int: + return preferences.skin_sensitivity.value - 1 + + def payload() -> list[int]: + return [ + 0x08, + (generation_code() << 1) + skin_sensitivity_code(), + duration_code(), + ] + + def checksum(payload: Iterable[int]) -> int: + return reduce(int.__add__, payload) + return self._command( - [0xFF, 0x08, 0x00, 0x00, 0x08], 'MSG_START_HEATING' + [ + 0xFF, + *payload(), + checksum(payload()), + ], + 'MSG_START_HEATING', ) def _command( @@ -70,9 +98,11 @@ def self_test(self) -> None: logger.debug('Response: %s', self.test_bootloader().hex(' ')) logger.debug('Response: %s', self.get_status().hex(' ')) - def start_heating(self) -> None: + def start_heating(self, preferences: Preferences) -> None: """Tells the device to start heating up.""" - logger.debug('Response: %s', self.msg_start_heating().hex(' ')) + logger.debug( + 'Response: %s', self.msg_start_heating(preferences).hex(' ') + ) logger.info('Device now preheating.') logger.info('Watch the LED closely.') diff --git a/tests/test_heat_it.py b/tests/test_heat_it.py index 6fea1cc..3afd3ea 100644 --- a/tests/test_heat_it.py +++ b/tests/test_heat_it.py @@ -65,6 +65,12 @@ def test_no_preferences(bulk_transfer: pytest_mock.MockType) -> None: ), StartParams(generation='child'), StartParams(generation=Generation.CHILD), + StartParams(duration=Duration.SHORT), + StartParams(duration='short', skin_sensitivity='sensitive'), + StartParams( + duration=Duration.SHORT, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), StartParams(skin_sensitivity='sensitive'), StartParams(skin_sensitivity=SkinSensitivity.SENSITIVE), StartParams( @@ -77,6 +83,11 @@ def test_no_preferences(bulk_transfer: pytest_mock.MockType) -> None: generation='child', skin_sensitivity='sensitive', ), + StartParams( + duration=Duration.SHORT, + generation=Generation.CHILD, + skin_sensitivity='sensitive', + ), StartParams( duration=Duration.SHORT, generation=Generation.CHILD, @@ -84,13 +95,468 @@ def test_no_preferences(bulk_transfer: pytest_mock.MockType) -> None: ), ], ) -def test_default_preferences( +def test_child_sensitive_short( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: Api().start(**asdict(preferences)) bulk_transfer.assert_called_with( - ANY, [0xFF, 0x08, 0x00, 0x00, 0x08] + ANY, + [0xFF, 0x08, 0x00, 0x00, 0x08], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='medium'), + StartParams(duration=Duration.MEDIUM), + StartParams(duration='medium', generation='child'), + StartParams( + duration=Duration.MEDIUM, generation=Generation.CHILD + ), + StartParams(duration='medium', skin_sensitivity='sensitive'), + StartParams( + duration=Duration.MEDIUM, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + StartParams( + duration='medium', + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.CHILD, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_child_sensitive_medium( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x00, 0x01, 0x09], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='long'), + StartParams(duration=Duration.LONG), + StartParams(duration='long', generation='child'), + StartParams( + duration=Duration.LONG, generation=Generation.CHILD + ), + StartParams(duration='long', skin_sensitivity='sensitive'), + StartParams( + duration=Duration.LONG, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + StartParams( + duration='long', + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.CHILD, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_child_sensitive_long( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x00, 0x02, 0x0A], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='short', generation='adult'), + StartParams( + duration=Duration.SHORT, generation=Generation.ADULT + ), + StartParams(generation='adult'), + StartParams(generation=Generation.ADULT), + StartParams( + duration='short', + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.ADULT, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_adult_sensitive_short( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x02, 0x00, 0x0A], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='medium', generation='adult'), + StartParams( + duration=Duration.MEDIUM, generation=Generation.ADULT + ), + StartParams( + duration='medium', + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.ADULT, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_adult_sensitive_medium( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x02, 0x01, 0x0B], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='long', generation='adult'), + StartParams( + duration=Duration.LONG, generation=Generation.ADULT + ), + StartParams( + duration='long', + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.ADULT, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_adult_sensitive_long( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x02, 0x02, 0x0C], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='short', skin_sensitivity='regular'), + StartParams( + duration=Duration.SHORT, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + StartParams(skin_sensitivity='regular'), + StartParams(skin_sensitivity=SkinSensitivity.REGULAR), + StartParams( + duration='short', + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.CHILD, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_child_regular_short( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x01, 0x00, 0x09], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='medium', skin_sensitivity='regular'), + StartParams( + duration=Duration.MEDIUM, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + StartParams( + duration='medium', + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.CHILD, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_child_regular_medium( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x01, 0x01, 0x0A], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='long', skin_sensitivity='regular'), + StartParams( + duration=Duration.LONG, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + StartParams( + duration='long', + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.CHILD, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_child_regular_long( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x01, 0x02, 0x0B], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(generation='adult', skin_sensitivity='regular'), + StartParams( + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + StartParams( + duration='short', + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.ADULT, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_adult_regular_short( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x03, 0x00, 0x0B], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams( + duration='medium', + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.ADULT, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_adult_regular_medium( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x03, 0x01, 0x0C], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams( + duration='long', + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.ADULT, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_adult_regular_long( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**asdict(preferences)) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x03, 0x02, 0x0D], ) From 750be80deac58a123cf909af4e361b0e7a5ec0cb Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Tue, 30 Jul 2024 13:50:22 +0200 Subject: [PATCH 5/5] Set default prefs in only one place --- itchcraft/api.py | 13 ------------- tests/test_heat_it.py | 43 +++++++++++++++++++++++++++---------------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/itchcraft/api.py b/itchcraft/api.py index 9363b4e..0e5a8c8 100644 --- a/itchcraft/api.py +++ b/itchcraft/api.py @@ -1,7 +1,6 @@ """The primary module in itchcraft.""" from contextlib import ExitStack -from dataclasses import dataclass from . import devices, prefs from .errors import CliError @@ -17,17 +16,6 @@ logger = get_logger(__name__) -@dataclass(frozen=True) -class StartParams: - """Parameters for the `start` method or CLI subcommand.""" - - duration: CliEnum[Duration] = prefs.default(Duration) - generation: CliEnum[Generation] = prefs.default(Generation) - skin_sensitivity: CliEnum[SkinSensitivity] = prefs.default( - SkinSensitivity - ) - - # pylint: disable=too-few-public-methods class Api: """Tech demo for interfacing with heat-based USB insect bite healers""" @@ -35,7 +23,6 @@ class Api: # pylint: disable=no-self-use def start( self, - # Re-enumerating all the `StartParams` fields to make Fire happy duration: CliEnum[Duration] = prefs.default(Duration), generation: CliEnum[Generation] = prefs.default(Generation), skin_sensitivity: CliEnum[SkinSensitivity] = prefs.default( diff --git a/tests/test_heat_it.py b/tests/test_heat_it.py index 3afd3ea..394f474 100644 --- a/tests/test_heat_it.py +++ b/tests/test_heat_it.py @@ -5,19 +5,30 @@ AbstractContextManager, nullcontext, ) -from dataclasses import asdict -from typing import Optional, Union +from typing import Optional, TypedDict, Union from unittest.mock import ANY import pytest import pytest_mock from itchcraft import Api, devices -from itchcraft.api import StartParams from itchcraft.backend import BulkTransferDevice from itchcraft.device import Device from itchcraft.heat_it import HeatItDevice -from itchcraft.prefs import Duration, Generation, SkinSensitivity +from itchcraft.prefs import ( + CliEnum, + Duration, + Generation, + SkinSensitivity, +) + + +class StartParams(TypedDict, total=False): + """Parameters for the `start` method in parameterized tests.""" + + duration: CliEnum[Duration] + generation: CliEnum[Generation] + skin_sensitivity: CliEnum[SkinSensitivity] @pytest.fixture(name='dummy_device') @@ -99,7 +110,7 @@ def test_child_sensitive_short( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x00, 0x00, 0x08], @@ -146,7 +157,7 @@ def test_child_sensitive_medium( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x00, 0x01, 0x09], @@ -193,7 +204,7 @@ def test_child_sensitive_long( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x00, 0x02, 0x0A], @@ -235,7 +246,7 @@ def test_adult_sensitive_short( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x02, 0x00, 0x0A], @@ -275,7 +286,7 @@ def test_adult_sensitive_medium( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x02, 0x01, 0x0B], @@ -315,7 +326,7 @@ def test_adult_sensitive_long( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x02, 0x02, 0x0C], @@ -358,7 +369,7 @@ def test_child_regular_short( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x01, 0x00, 0x09], @@ -399,7 +410,7 @@ def test_child_regular_medium( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x01, 0x01, 0x0A], @@ -440,7 +451,7 @@ def test_child_regular_long( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x01, 0x02, 0x0B], @@ -481,7 +492,7 @@ def test_adult_regular_short( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x03, 0x00, 0x0B], @@ -517,7 +528,7 @@ def test_adult_regular_medium( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x03, 0x01, 0x0C], @@ -553,7 +564,7 @@ def test_adult_regular_long( bulk_transfer: pytest_mock.MockType, preferences: StartParams, ) -> None: - Api().start(**asdict(preferences)) + Api().start(**preferences) bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x03, 0x02, 0x0D],