diff --git a/.devcontainer/dev.Dockerfile b/.devcontainer/dev.Dockerfile index ea51f3a208..02f9bb1159 100644 --- a/.devcontainer/dev.Dockerfile +++ b/.devcontainer/dev.Dockerfile @@ -29,7 +29,7 @@ USER vscode # Define the version of Poetry to install (default is 1.4.2) # Define the directory of python virtual environment ARG PYTHON_VIRTUALENV_HOME=/home/vscode/ukbc-py-env \ - POETRY_VERSION=1.8.3 + POETRY_VERSION=1.8.4 ENV POETRY_VIRTUALENVS_IN_PROJECT=false \ POETRY_NO_INTERACTION=true diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 92712a2085..42ea8b272c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -47,7 +47,10 @@ "python.testing.autoTestDiscoverOnSaveEnabled": false, "python.defaultInterpreterPath": "/home/vscode/ukbc-py-env", "python.testing.pytestArgs": [ + "--disable-socket", + "--allow-unix-socket", "${workspaceFolder}/uk_bin_collection", + "${workspaceFolder}/custom_components/uk_bin_collection/tests", "--headless=False", "-o cache_dir=${workspaceFolder}/.pytest_cache" ], diff --git a/.github/ISSUE_TEMPLATE/HOME_ASSISTANT_CUSTOM_COMPONENT_ISSUE.yaml b/.github/ISSUE_TEMPLATE/HOME_ASSISTANT_CUSTOM_COMPONENT_ISSUE.yaml index 87133b3c99..b93123db6e 100644 --- a/.github/ISSUE_TEMPLATE/HOME_ASSISTANT_CUSTOM_COMPONENT_ISSUE.yaml +++ b/.github/ISSUE_TEMPLATE/HOME_ASSISTANT_CUSTOM_COMPONENT_ISSUE.yaml @@ -5,7 +5,7 @@ labels: ["bug", "home assistant custom component"] body: - type: markdown attributes: - value: If you were trying to add a specific council, please check it is listed as working [here](https://robbrad.github.io/UKBinCollectionData/3.11/) and open a [Council Issue](https://github.com/robbrad/UKBinCollectionData/issues/new/choose) instead if it's failing + value: If you were trying to add a specific council, please check it is listed as working [here](https://robbrad.github.io/UKBinCollectionData/3.12/) and open a [Council Issue](https://github.com/robbrad/UKBinCollectionData/issues/new/choose) instead if it's failing - type: input id: ha_version attributes: @@ -46,7 +46,7 @@ body: options: - label: I searched for similar issues at https://github.com/robbrad/UKBinCollectionData/issues?q=is:issue and found no duplicates required: true - - label: If trying to add a specific council, I've checked it is listed as working at https://robbrad.github.io/UKBinCollectionData/3.11/ + - label: If trying to add a specific council, I've checked it is listed as working at https://robbrad.github.io/UKBinCollectionData/3.12/ required: true - label: I have provided a detailed explanation of the issue as well as steps to replicate the issue required: true diff --git a/.github/workflows/behave.yml b/.github/workflows/behave.yml index 26960db81c..ea2f5b7b24 100644 --- a/.github/workflows/behave.yml +++ b/.github/workflows/behave.yml @@ -3,71 +3,51 @@ name: Test Councils on: workflow_dispatch: push: - # Trigger unless only the wiki directory changed paths-ignore: - "wiki/**" - - "**/**.md" + - "**/*.md" - "uk_bin_collection_api_server/**" branches: [ "master" ] pull_request: - # Trigger unless only the wiki directory changed paths-ignore: - "wiki/**" - - "**/**.md" + - "**/*.md" - "uk_bin_collection_api_server/**" branches: [ "master" ] schedule: - - cron: '0 0 * * *' + - cron: '0 0 * * *' # Nightly schedule for full test run jobs: - build: - if: "!startsWith(github.event.head_commit.message, 'bump:')" + setup: + name: Setup Environment runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.11, 3.12] - poetry-version: [1.8.3] - services: - selenium: - image: selenium/standalone-chrome:latest - options: --shm-size=2gb --name selenium --hostname selenium - ports: - - 4444:4444 steps: - uses: actions/checkout@v4 - - name: Install poetry - run: pipx install poetry==${{ matrix.poetry-version }} + - name: Install Poetry + run: pipx install poetry==1.8.4 - uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} - #cache: 'poetry' + python-version: 3.12 - - name: Install + - name: Install Dependencies run: make install - name: Lint JSON run: jq empty uk_bin_collection/tests/input.json - - name: Check Parity of Councils / input.json / Feature file - run: | - repo=${{ github.event.pull_request.head.repo.full_name || 'robbrad/UKBinCollectionData' }} - branch=${{ github.event.pull_request.head.ref || 'master' }} - make parity-check repo=$repo branch=$branch - - - name: Get all councils files that have changed + - name: Get All Council Files That Have Changed id: changed-council-files uses: tj-actions/changed-files@v45 with: files: | uk_bin_collection/uk_bin_collection/councils/**.py - - name: Get all councils - env: - ALL_CHANGED_FILES: ${{ steps.changed-council-files.outputs.all_changed_files }} + - name: Set Council Tests Environment Variable + id: set-council-tests run: | - IFS=' ' read -ra FILES <<< "$ALL_CHANGED_FILES" + IFS=' ' read -ra FILES <<< "${{ steps.changed-council-files.outputs.all_changed_files }}" COUNCIL_TESTS="" for file in "${FILES[@]}"; do FILENAME=$(basename "$file" .py) @@ -77,89 +57,210 @@ jobs: COUNCIL_TESTS="$COUNCIL_TESTS or $FILENAME" fi done - echo "COUNCIL_TESTS=${COUNCIL_TESTS}" >> $GITHUB_ENV + echo "council_tests=$COUNCIL_TESTS" >> $GITHUB_OUTPUT - - name: Run integration tests - env: - HEADLESS: True - run: make matrix=${{ matrix.python-version }} councils="${{ env.COUNCIL_TESTS }}" integration-tests - continue-on-error: true + outputs: + council_tests: ${{ steps.set-council-tests.outputs.council_tests }} - - name: Run unit tests - run: make unit-tests - continue-on-error: true + unit-tests: + name: Run Unit Tests + needs: setup + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.12] + poetry-version: [1.8.4] + steps: + - uses: actions/checkout@v4 - #- name: Upload test coverage to Codecov - # uses: codecov/codecov-action@v4 - # with: - # gcov_ignore: uk_bin_collection/tests/** + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} - - name: Upload test results to Codecov + - name: Install Poetry + run: pipx install poetry==${{ matrix.poetry-version }} + + - name: Install Dependencies + run: make install + + - name: Run Unit Tests + run: make unit-tests + + - name: Upload Test Results to Codecov uses: codecov/codecov-action@v4 with: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} file: coverage.xml + parity-check: + name: Parity Check + needs: setup + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.12] + poetry-version: [1.8.4] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: pipx install poetry==${{ matrix.poetry-version }} + + - name: Install Dependencies + run: make install + + - name: Check Parity of Councils / input.json / Feature file + run: | + repo=${{ github.event.pull_request.head.repo.full_name || 'robbrad/UKBinCollectionData' }} + branch=${{ github.event.pull_request.head.ref || 'master' }} + make parity-check repo=$repo branch=$branch + + integration-tests: + name: Run Integration Tests + needs: setup + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.12] + poetry-version: [1.8.4] + services: + selenium: + image: selenium/standalone-chrome:latest + options: --shm-size=2gb --name selenium --hostname selenium + ports: + - 4444:4444 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: pipx install poetry==${{ matrix.poetry-version }} - - name: Get Allure history + - name: Install Dependencies + run: make install + + - name: Run Integration Tests + env: + HEADLESS: True + COUNCIL_TESTS: ${{ needs.setup.outputs.council_tests }} + run: make matrix=${{ matrix.python-version }} councils="${{ env.COUNCIL_TESTS }}" integration-tests + continue-on-error: true + + report: + name: Generate and Upload Reports + needs: [unit-tests, parity-check, integration-tests] + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.12] + steps: + - uses: actions/checkout@v4 + + # Fetch Allure History + - name: Get Allure history - Full Report + if: github.event_name == 'schedule' || github.event_name == 'push' uses: actions/checkout@v4 - if: github.ref == 'refs/heads/master' continue-on-error: true with: ref: gh-pages - path: gh-pages + path: gh-pages/allure-full-history + + - name: Get Allure history - Partial Report + if: github.event_name != 'schedule' && github.event_name != 'push' + uses: actions/checkout@v4 + continue-on-error: true + with: + ref: gh-pages + path: gh-pages/allure-partial-history + + # Generate Allure Reports + - name: Allure report action for Full Run + uses: simple-elf/allure-report-action@master + if: github.event_name == 'schedule' || github.event_name == 'push' + with: + allure_results: build/${{ matrix.python-version }}/allure-results + subfolder: ${{ matrix.python-version }} + allure_history: gh-pages/allure-full-history + keep_reports: 20 - - name: Allure report action from marketplace + - name: Allure report action for Partial Run uses: simple-elf/allure-report-action@master - if: github.ref == 'refs/heads/master' + if: github.event_name != 'schedule' && github.event_name != 'push' with: allure_results: build/${{ matrix.python-version }}/allure-results subfolder: ${{ matrix.python-version }} - allure_history: allure-history + allure_history: gh-pages/allure-partial-history keep_reports: 20 - - name: Tar report - if: github.ref == 'refs/heads/master' - run: tar -cvf allure_history_${{ matrix.python-version }}.tar allure-history/${{ matrix.python-version }} + # Archive Reports + - name: Tar full report + if: github.event_name == 'schedule' || github.event_name == 'push' + run: tar -cvf allure_full_history_${{ matrix.python-version }}.tar gh-pages/allure-full-history/${{ matrix.python-version }} + + - name: Tar partial report + if: github.event_name != 'schedule' && github.event_name != 'push' + run: tar -cvf allure_partial_history_${{ matrix.python-version }}.tar gh-pages/allure-partial-history/${{ matrix.python-version }} - - name: Upload artifact + # Upload Report Artifacts + - name: Upload artifact for Full Report uses: actions/upload-artifact@v4 - if: github.ref == 'refs/heads/master' + if: github.event_name == 'schedule' || github.event_name == 'push' with: - name: allure_history_${{ matrix.python-version }} - path: allure_history_${{ matrix.python-version }}.tar + name: allure_full_history_${{ matrix.python-version }} + path: allure_full_history_${{ matrix.python-version }}.tar + + - name: Upload artifact for Partial Report + uses: actions/upload-artifact@v4 + if: github.event_name != 'schedule' && github.event_name != 'push' + with: + name: allure_partial_history_${{ matrix.python-version }} + path: allure_partial_history_${{ matrix.python-version }}.tar deploy: + name: Deploy Reports runs-on: ubuntu-latest - needs: build + needs: report if: github.ref == 'refs/heads/master' steps: - - uses: actions/download-artifact@v4 - name: Download 3.11 artifacts + name: Download Full Artifacts with: - name: allure_history_3.11 - path: allure-history/tars + name: allure_full_history_3.12 + path: allure-history/tars/full - uses: actions/download-artifact@v4 - name: Download 3.12 artifacts + name: Download Partial Artifacts with: - name: allure_history_3.12 - path: allure-history/tars + name: allure_partial_history_3.12 + path: allure-history/tars/partial - - name: Untar reports - run: for i in allure-history/tars/*.tar; do tar -xvf "$i" allure-history ;done + - name: Untar Full Reports + run: for i in allure-history/tars/full/*.tar; do tar -xvf "$i" -C allure-history/full ;done - - name: Remove tar reports + - name: Untar Partial Reports + run: for i in allure-history/tars/partial/*.tar; do tar -xvf "$i" -C allure-history/partial ;done + + - name: Remove Tar Reports run: rm -rf allure-history/tars - - name: Display structure of downloaded files - run: ls -R + - name: Deploy Full Report + uses: peaceiris/actions-gh-pages@v4 + with: + personal_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: allure-history/full - - name: Deploy + - name: Deploy Partial Report uses: peaceiris/actions-gh-pages@v4 with: - PERSONAL_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PUBLISH_BRANCH: gh-pages - PUBLISH_DIR: allure-history + personal_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: allure-history/partial diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9644c6f606..f4cc21c1c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - name: Run image uses: abatilo/actions-poetry@v3.0.1 with: - poetry-version: '1.8.3' + poetry-version: '1.8.4' - name: Install dependencies run: | diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 3dd0a2eaf0..e32b00a1f8 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -30,7 +30,7 @@ jobs: - name: Run image uses: abatilo/actions-poetry@v3.0.1 with: - poetry-version: '1.8.3' + poetry-version: '1.8.4' - name: Install run: make install diff --git a/.gitignore b/.gitignore index 8320f59ccb..2f4f8aa8c0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ !*.Dockerfile !docker-compose.yml !.vscode/launch.json +!pytest.ini # Or these folders... !.github diff --git a/.vscode/launch.json b/.vscode/launch.json index 354327fc3f..62c35d173e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,7 +24,7 @@ "debug-test" ], "env": { - "PYTEST_ADDOPTS": "--headless=False --local_browser=False" + "PYTEST_ADDOPTS": "--headless=False --local_browser=False --disable-socket --allow-unix-socket" }, } ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da26abfb8c..c762f41b79 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ This document contains guidelines on contributing to the UKBCD project including the environment, how we use our issue tracker, and how you can develop more scrapers. ## Getting Started -You will need to install Python on the system you plan to run the script from. Python 3.11 and 3.12 are tested on this project . +You will need to install Python on the system you plan to run the script from. Python 3.12 is tested on this project . The project uses [poetry](https://python-poetry.org/docs/) to manage dependencies and setup the build environment. diff --git a/Makefile b/Makefile index 0ad295e93c..be5e210011 100644 --- a/Makefile +++ b/Makefile @@ -24,9 +24,9 @@ pycodestyle: ## @Testing runs unit tests integration-tests: ## runs tests for the project if [ -z "$(councils)" ]; then \ - poetry run pytest uk_bin_collection/tests/step_defs/ -n logical --alluredir=build/$(matrix)/allure-results; \ + poetry run pytest --disable-socket --allow-unix-socket uk_bin_collection/tests/step_defs/ -n logical --alluredir=build/$(matrix)/allure-results; \ else \ - poetry run pytest uk_bin_collection/tests/step_defs/ -k "$(councils)" -n logical --alluredir=build/$(matrix)/allure-results; \ + poetry run pytest --disable-socket --allow-unix-socket uk_bin_collection/tests/step_defs/ -k "$(councils)" -n logical --alluredir=build/$(matrix)/allure-results; \ fi parity-check: @@ -34,7 +34,7 @@ parity-check: unit-tests: poetry run coverage erase - - poetry run coverage run --append --omit "*/tests/*" -m pytest uk_bin_collection/tests --ignore=uk_bin_collection/tests/step_defs/ + - poetry run coverage run --append --omit "*/tests/*" -m pytest --disable-socket --allow-unix-socket uk_bin_collection/tests custom_components/uk_bin_collection/tests --ignore=uk_bin_collection/tests/step_defs/ poetry run coverage xml update-wiki: diff --git a/README.md b/README.md index 28b0a14dee..48ae4e7308 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,6 @@ docker pull selenium/standalone-chrome docker run -d -p 4444:4444 --name seleniu ## Reports -- [3.11](https://robbrad.github.io/UKBinCollectionData/3.11/) - [3.12](https://robbrad.github.io/UKBinCollectionData/3.12/) --- diff --git a/conftest.py b/conftest.py index f3d7b29cbb..d121d01f00 100644 --- a/conftest.py +++ b/conftest.py @@ -2,6 +2,14 @@ from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest +from pytest_socket import enable_socket, disable_socket, socket_allow_hosts + +@pytest.hookimpl(trylast=True) +def pytest_runtest_setup(): + enable_socket() + socket_allow_hosts(None) # Allow all hosts + +## Integration Tests def pytest_addoption(parser: Parser) -> None: parser.addoption("--headless", action="store", default="True", type=str) parser.addoption("--local_browser", action="store", default="False", type=str) diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/custom_components/uk_bin_collection/config_flow.py b/custom_components/uk_bin_collection/config_flow.py index 993d18c021..3be66db6b4 100644 --- a/custom_components/uk_bin_collection/config_flow.py +++ b/custom_components/uk_bin_collection/config_flow.py @@ -20,13 +20,20 @@ def __init__(self): self.councils_data = None async def get_councils_json(self) -> object: - """Returns an object of supported council's and their required fields.""" - # Fetch the JSON data from the provided URL - url = "https://raw.githubusercontent.com/robbrad/UKBinCollectionData/0.107.0/uk_bin_collection/tests/input.json" - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - data_text = await response.text() - return json.loads(data_text) + """Returns an object of supported councils and their required fields.""" + url = "https://raw.githubusercontent.com/robbrad/UKBinCollectionData/0.104.0/uk_bin_collection/tests/input.json" + try: + async with aiohttp.ClientSession() as session: + try: + async with session.get(url) as response: + data_text = await response.text() + return json.loads(data_text) + except Exception as e: + _LOGGER.error("Failed to fetch data from URL: %s", e) + raise + except Exception as e: + _LOGGER.error("Failed to create aiohttp ClientSession: %s", e) + return {} async def get_council_schema(self, council=str) -> vol.Schema: """Returns a config flow form schema based on a specific council's fields.""" @@ -83,6 +90,10 @@ async def async_step_user(self, user_input=None): errors = {} self.councils_data = await self.get_councils_json() + if not self.councils_data: + _LOGGER.error("Council data is unavailable.") + return self.async_abort(reason="council_data_unavailable") + self.council_names = list(self.councils_data.keys()) self.council_options = [ self.councils_data[name]["wiki_name"] for name in self.council_names @@ -131,7 +142,8 @@ async def async_step_council(self, user_input=None): errors = {} if user_input is not None: - if "skip_get_url" in self.councils_data[self.data["council"]]: + # Check the value of 'skip_get_url' rather than just its presence + if self.councils_data[self.data["council"]].get("skip_get_url", False): user_input["skip_get_url"] = True user_input["url"] = self.councils_data[self.data["council"]]["url"] @@ -152,7 +164,7 @@ async def async_step_council(self, user_input=None): async def async_step_init(self, user_input=None): """Handle a flow initiated by the user.""" _LOGGER.info(LOG_PREFIX + "Initiating flow with user input: %s", user_input) - return await self.async_step_user(user_input) + return await self.async_step_user(user_input=user_input) async def async_step_reconfigure(self, user_input=None): """Handle reconfiguration of the integration.""" diff --git a/custom_components/uk_bin_collection/sensor.py b/custom_components/uk_bin_collection/sensor.py index cb194496be..f917a9ece0 100644 --- a/custom_components/uk_bin_collection/sensor.py +++ b/custom_components/uk_bin_collection/sensor.py @@ -1,21 +1,25 @@ -"""Support for UK Bin Collection Dat sensors.""" +"""Support for UK Bin Collection Data sensors.""" -from datetime import timedelta, datetime -from dateutil import parser -import async_timeout +from datetime import datetime, timedelta import json +from json import JSONDecodeError +import logging +from dateutil import parser +import async_timeout +from homeassistant.core import HomeAssistant, callback +from homeassistant.config_entries import ConfigEntry from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.event import async_track_time_interval - -from homeassistant.core import callback -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, + UpdateFailed, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util +from homeassistant.helpers.entity_platform import AddEntitiesCallback + from .const import ( DOMAIN, LOG_PREFIX, @@ -24,77 +28,81 @@ DEVICE_CLASS, STATE_ATTR_COLOUR, ) - -"""The UK Bin Collection Data integration.""" -from homeassistant.core import HomeAssistant -from homeassistant.helpers.start import async_at_started -from homeassistant.helpers.entity_platform import AddEntitiesCallback - from uk_bin_collection.uk_bin_collection.collect_data import UKBinCollectionApp -import logging - _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config, async_add_entities): - """Set up the sensor platform.""" - _LOGGER.info(LOG_PREFIX + "Setting up UK Bin Collection Data platform.") - _LOGGER.info(LOG_PREFIX + "Data Supplied: %s", config.data) - - name = config.data.get("name", "") - timeout = config.data.get("timeout", 60) - icon_color_mapping = config.data.get( - "icon_color_mapping", "{}" - ) # Use an empty JSON object as default - +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback +) -> None: + """ + Set up the UK Bin Collection Data sensor platform. + + This function initializes the sensor entities based on the configuration entry. + It sets up the data coordinator and creates sensors for each bin type along with + their respective attributes and a raw JSON sensor. + """ + _LOGGER.info(f"{LOG_PREFIX} Setting up UK Bin Collection Data platform.") + _LOGGER.debug(f"{LOG_PREFIX} Data Supplied: %s", config_entry.data) + + name = config_entry.data.get("name", "UK Bin Collection") + timeout = config_entry.data.get("timeout", 60) + icon_color_mapping = config_entry.data.get("icon_color_mapping", "{}") # Default to empty JSON + + # Validate and sanitize 'timeout' + try: + timeout = int(timeout) + except (ValueError, TypeError): + _LOGGER.warning(f"{LOG_PREFIX} Invalid timeout value: {timeout}. Using default 60 seconds.") + timeout = 60 + + excluded_keys = { + "name", "council", "url", "skip_get_url", "headless", + "local_browser", "timeout", "icon_color_mapping", + } + + # Construct arguments for UKBinCollectionApp args = [ - config.data.get("council", ""), - config.data.get("url", ""), - *( - f"--{key}={value}" - for key, value in config.data.items() - if key - not in { - "name", - "council", - "url", - "skip_get_url", - "headless", - "local_browser", - "timeout", - "icon_color_mapping", # Exclude this key, even if empty - } - ), + config_entry.data.get("council", ""), + config_entry.data.get("url", ""), + *(f"--{key}={value}" for key, value in config_entry.data.items() if key not in excluded_keys), ] - if config.data.get("skip_get_url", False): + + if config_entry.data.get("skip_get_url", False): args.append("--skip_get_url") - headless = config.data.get("headless", True) + headless = config_entry.data.get("headless", True) if not headless: args.append("--not-headless") - local_browser = config.data.get("local_browser", False) + local_browser = config_entry.data.get("local_browser", False) if local_browser: args.append("--local_browser") - _LOGGER.info(f"{LOG_PREFIX} UKBinCollectionApp args: {args}") + _LOGGER.debug(f"{LOG_PREFIX} UKBinCollectionApp args: {args}") + # Initialize the UK Bin Collection Data application ukbcd = UKBinCollectionApp() ukbcd.set_args(args) - coordinator = HouseholdBinCoordinator(hass, ukbcd, name, timeout=timeout) - await coordinator.async_config_entry_first_refresh() + # Initialize the data coordinator + coordinator = HouseholdBinCoordinator( + hass, ukbcd, name, config_entry, timeout=timeout + ) + await coordinator.async_refresh() + + # Store the coordinator in Home Assistant's data + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = coordinator entities = [] for bin_type in coordinator.data.keys(): device_id = f"{name}_{bin_type}" - entities.append( - UKBinCollectionDataSensor( - coordinator, bin_type, device_id, icon_color_mapping - ) - ) - entities.append( + entities.extend([ + UKBinCollectionDataSensor(coordinator, bin_type, device_id, icon_color_mapping), UKBinCollectionAttributeSensor( coordinator, bin_type, @@ -102,9 +110,7 @@ async def async_setup_entry(hass, config, async_add_entities): "Colour", device_id, icon_color_mapping, - ) - ) - entities.append( + ), UKBinCollectionAttributeSensor( coordinator, bin_type, @@ -112,9 +118,7 @@ async def async_setup_entry(hass, config, async_add_entities): "Next Collection Human Readable", device_id, icon_color_mapping, - ) - ) - entities.append( + ), UKBinCollectionAttributeSensor( coordinator, bin_type, @@ -122,9 +126,7 @@ async def async_setup_entry(hass, config, async_add_entities): "Days Until Collection", device_id, icon_color_mapping, - ) - ) - entities.append( + ), UKBinCollectionAttributeSensor( coordinator, bin_type, @@ -132,9 +134,7 @@ async def async_setup_entry(hass, config, async_add_entities): "Bin Type", device_id, icon_color_mapping, - ) - ) - entities.append( + ), UKBinCollectionAttributeSensor( coordinator, bin_type, @@ -142,20 +142,37 @@ async def async_setup_entry(hass, config, async_add_entities): "Next Collection Date", device_id, icon_color_mapping, - ) - ) + ), + ]) - # Add the new Raw JSON Sensor + # Add the Raw JSON Sensor entities.append(UKBinCollectionRawJSONSensor(coordinator, f"{name}_raw_json", name)) + # Register all sensor entities with Home Assistant async_add_entities(entities) class HouseholdBinCoordinator(DataUpdateCoordinator): - """Household Bin Coordinator""" + """Coordinator to manage fetching and updating UK Bin Collection data.""" - def __init__(self, hass, ukbcd, name, timeout=60): - """Initialize the coordinator.""" + def __init__( + self, + hass: HomeAssistant, + ukbcd: UKBinCollectionApp, + name: str, + config_entry: ConfigEntry, + timeout: int = 60, + ) -> None: + """ + Initialize the data coordinator. + + Args: + hass: Home Assistant instance. + ukbcd: Instance of UKBinCollectionApp to fetch data. + name: Name of the sensor. + config_entry: Configuration entry. + timeout: Timeout for data fetching in seconds. + """ super().__init__( hass, _LOGGER, @@ -165,57 +182,113 @@ def __init__(self, hass, ukbcd, name, timeout=60): self.ukbcd = ukbcd self.name = name self.timeout = timeout + self.config_entry = config_entry + + async def _async_update_data(self) -> dict: + """ + Fetch and process the latest bin collection data. + + Returns: + A dictionary containing the latest collection information. + + Raises: + UpdateFailed: If there is an error fetching or processing the data. + """ + try: + async with async_timeout.timeout(self.timeout): + _LOGGER.debug(f"{LOG_PREFIX} UKBinCollectionApp Updating") + data = await self.hass.async_add_executor_job(self.ukbcd.run) + parsed_data = json.loads(data) + return get_latest_collection_info(parsed_data) + except (async_timeout.TimeoutError, JSONDecodeError) as exc: + _LOGGER.error(f"{LOG_PREFIX} Error updating data: {exc}") + raise UpdateFailed(f"Error updating data: {exc}") from exc + except Exception as exc: + _LOGGER.exception(f"{LOG_PREFIX} Unexpected error: {exc}") + raise UpdateFailed(f"Unexpected error: {exc}") from exc + + +def get_latest_collection_info(data: dict) -> dict: + """ + Process the raw bin collection data to determine the next collection dates. + + Args: + data: Raw data from UK Bin Collection API. + + Returns: + A dictionary mapping bin types to their next collection dates. + """ + current_date = dt_util.now() + next_collection_dates = {} - async def _async_update_data(self): - async with async_timeout.timeout(self.timeout): - _LOGGER.info(f"{LOG_PREFIX} UKBinCollectionApp Updating") - data = await self.hass.async_add_executor_job(self.ukbcd.run) - return get_latest_collection_info(json.loads(data)) - + for bin_data in data.get("bins", []): + bin_type = bin_data.get("type") + collection_date_str = bin_data.get("collectionDate") -def get_latest_collection_info(data) -> dict: - """Process the bin collection data.""" - current_date = datetime.now() - next_collection_dates = {} + if not bin_type or not collection_date_str: + _LOGGER.warning(f"{LOG_PREFIX} Missing 'type' or 'collectionDate' in bin data: {bin_data}") + continue # Skip entries with missing fields - for bin_data in data["bins"]: - bin_type = bin_data["type"] - collection_date_str = bin_data["collectionDate"] - collection_date = datetime.strptime(collection_date_str, "%d/%m/%Y") + try: + collection_date = datetime.strptime(collection_date_str, "%d/%m/%Y") + except (ValueError, TypeError): + _LOGGER.warning(f"{LOG_PREFIX} Invalid date format for bin type '{bin_type}': '{collection_date_str}'.") + continue # Skip entries with invalid date formats + # Ensure the collection date is today or in the future if collection_date.date() >= current_date.date(): - if bin_type in next_collection_dates: - if collection_date < datetime.strptime( - next_collection_dates[bin_type], "%d/%m/%Y" - ): - next_collection_dates[bin_type] = collection_date_str - else: + existing_date_str = next_collection_dates.get(bin_type) + if (not existing_date_str) or (collection_date < datetime.strptime(existing_date_str, "%d/%m/%Y")): next_collection_dates[bin_type] = collection_date_str - _LOGGER.info(f"{LOG_PREFIX} Next Collection Dates: {next_collection_dates}") + _LOGGER.debug(f"{LOG_PREFIX} Next Collection Dates: {next_collection_dates}") return next_collection_dates class UKBinCollectionDataSensor(CoordinatorEntity, SensorEntity): - """Implementation of the UK Bin Collection Data sensor.""" + """Sensor entity for individual bin collection data.""" - device_class = DEVICE_CLASS + _attr_device_class = DEVICE_CLASS def __init__( - self, coordinator, bin_type, device_id, icon_color_mapping=None + self, + coordinator: HouseholdBinCoordinator, + bin_type: str, + device_id: str, + icon_color_mapping: str = "{}", ) -> None: - """Initialize the main bin sensor.""" + """ + Initialize the main bin sensor. + + Args: + coordinator: Data coordinator instance. + bin_type: Type of the bin (e.g., recycling, waste). + device_id: Unique identifier for the device. + icon_color_mapping: JSON string mapping bin types to icons and colors. + """ super().__init__(coordinator) self._bin_type = bin_type self._device_id = device_id - self._icon_color_mapping = ( - json.loads(icon_color_mapping) if icon_color_mapping else {} - ) + self._state = None + self._next_collection = None + self._days = None + self._icon = None + self._color = None + + # Load icon and color mappings + try: + self._icon_color_mapping = json.loads(icon_color_mapping) if icon_color_mapping else {} + except JSONDecodeError: + _LOGGER.warning( + f"{LOG_PREFIX} Invalid icon_color_mapping JSON: {icon_color_mapping}. Using default settings." + ) + self._icon_color_mapping = {} + self.apply_values() @property - def device_info(self): - """Return device information for each bin.""" + def device_info(self) -> dict: + """Return device information for device registry.""" return { "identifiers": {(DOMAIN, self._device_id)}, "name": f"{self.coordinator.name} {self._bin_type}", @@ -226,168 +299,220 @@ def device_info(self): @callback def _handle_coordinator_update(self) -> None: - """Handle updates from the coordinator.""" + """Handle updates from the coordinator and refresh sensor state.""" self.apply_values() self.async_write_ha_state() - def apply_values(self): - """Apply values to the sensor.""" - self._next_collection = parser.parse( - self.coordinator.data[self._bin_type], dayfirst=True - ).date() - now = dt_util.now() - self._days = (self._next_collection - now.date()).days - - # Use user-supplied icon and color if available - self._icon = self._icon_color_mapping.get(self._bin_type, {}).get("icon") - self._color = self._icon_color_mapping.get(self._bin_type, {}).get("color") - - # Fall back to default logic if icon or color is not provided - if not self._icon: - if "recycling" in self._bin_type.lower(): - self._icon = "mdi:recycle" - elif "waste" in self._bin_type.lower(): - self._icon = "mdi:trash-can" - else: - self._icon = "mdi:delete" - - if not self._color: - self._color = "black" # Default color - - # Set the state based on the collection day - if self._next_collection == now.date(): - self._state = "Today" - elif self._next_collection == (now + timedelta(days=1)).date(): - self._state = "Tomorrow" + def apply_values(self) -> None: + """Apply the latest data to the sensor's state and attributes.""" + bin_data = self.coordinator.data.get(self._bin_type) + if bin_data: + try: + self._next_collection = parser.parse(bin_data, dayfirst=True).date() + now = dt_util.now().date() + self._days = (self._next_collection - now).days + + # Set icon and color based on mapping or defaults + bin_mapping = self._icon_color_mapping.get(self._bin_type, {}) + self._icon = bin_mapping.get("icon") or self.get_default_icon() + self._color = bin_mapping.get("color") or "black" + + # Determine state based on collection date + if self._next_collection == now: + self._state = "Today" + elif self._next_collection == now + timedelta(days=1): + self._state = "Tomorrow" + else: + day_label = "day" if self._days == 1 else "days" + self._state = f"In {self._days} {day_label}" + except (ValueError, TypeError) as exc: + _LOGGER.warning( + f"{LOG_PREFIX} Error parsing collection date for '{self._bin_type}': {exc}" + ) + self._state = "Unknown" + self._next_collection = None + self._days = None + self._icon = "mdi:delete-alert" + self._color = "grey" else: - self._state = f"In {self._days} days" + _LOGGER.warning(f"{LOG_PREFIX} Data for bin type '{self._bin_type}' is missing.") + self._state = "Unknown" + self._next_collection = None + self._days = None + self._icon = "mdi:delete-alert" + self._color = "grey" + + def get_default_icon(self) -> str: + """Return a default icon based on the bin type.""" + if "recycling" in self._bin_type.lower(): + return "mdi:recycle" + elif "waste" in self._bin_type.lower(): + return "mdi:trash-can" + else: + return "mdi:delete" @property - def name(self): - """Return the name of the bin.""" + def name(self) -> str: + """Return the name of the sensor.""" return f"{self.coordinator.name} {self._bin_type}" @property - def state(self): - """Return the state of the bin.""" - return self._state + def state(self) -> str: + """Return the current state of the sensor.""" + return self._state if self._state else "Unknown" @property - def icon(self): - """Return the entity icon.""" - return self._icon + def icon(self) -> str: + """Return the icon for the sensor.""" + return self._icon if self._icon else "mdi:alert" @property - def extra_state_attributes(self): - """Return extra attributes of the sensor.""" + def extra_state_attributes(self) -> dict: + """Return extra state attributes for the sensor.""" return { STATE_ATTR_COLOUR: self._color, - STATE_ATTR_NEXT_COLLECTION: self._next_collection.strftime("%d/%m/%Y"), + STATE_ATTR_NEXT_COLLECTION: self._next_collection.strftime("%d/%m/%Y") if self._next_collection else None, STATE_ATTR_DAYS: self._days, } @property - def color(self): - """Return the entity icon.""" - return self._color + def color(self) -> str: + """Return the color associated with the bin.""" + return self._color if self._color else "grey" + + @property + def available(self) -> bool: + """Return the availability of the sensor.""" + return self._state not in [None, "Unknown"] @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID for the sensor.""" return self._device_id class UKBinCollectionAttributeSensor(CoordinatorEntity, SensorEntity): - """Implementation of the attribute sensors (Colour, Next Collection, Days, Bin Type, Raw Next Collection).""" + """Sensor entity for additional attributes of a bin.""" def __init__( self, - coordinator, - bin_type, - unique_id, - attribute_type, - device_id, - icon_color_mapping=None, + coordinator: HouseholdBinCoordinator, + bin_type: str, + unique_id: str, + attribute_type: str, + device_id: str, + icon_color_mapping: str = "{}", ) -> None: - """Initialize the attribute sensor.""" + """ + Initialize the attribute sensor. + + Args: + coordinator: Data coordinator instance. + bin_type: Type of the bin (e.g., recycling, waste). + unique_id: Unique identifier for the sensor. + attribute_type: The specific attribute this sensor represents. + device_id: Unique identifier for the device. + icon_color_mapping: JSON string mapping bin types to icons and colors. + """ super().__init__(coordinator) self._bin_type = bin_type self._unique_id = unique_id self._attribute_type = attribute_type self._device_id = device_id - self._icon_color_mapping = ( - json.loads(icon_color_mapping) if icon_color_mapping else {} - ) - - # Use user-supplied icon and color if available - self._icon = self._icon_color_mapping.get(self._bin_type, {}).get("icon") - self._color = self._icon_color_mapping.get(self._bin_type, {}).get("color") - # Fall back to default logic if icon or color is not provided - if not self._icon: - if "recycling" in self._bin_type.lower(): - self._icon = "mdi:recycle" - elif "waste" in self._bin_type.lower(): - self._icon = "mdi:trash-can" - else: - self._icon = "mdi:delete" - - if not self._color: - self._color = "black" # Default color + # Load icon and color mappings + try: + self._icon_color_mapping = json.loads(icon_color_mapping) if icon_color_mapping else {} + except JSONDecodeError: + _LOGGER.warning( + f"{LOG_PREFIX} Invalid icon_color_mapping JSON: {icon_color_mapping}. Using default settings." + ) + self._icon_color_mapping = {} + + # Set icon and color based on mapping or defaults + bin_mapping = self._icon_color_mapping.get(self._bin_type, {}) + self._icon = bin_mapping.get("icon") or self.get_default_icon() + self._color = bin_mapping.get("color") or "black" + + def get_default_icon(self) -> str: + """Return a default icon based on the bin type.""" + if "recycling" in self._bin_type.lower(): + return "mdi:recycle" + elif "waste" in self._bin_type.lower(): + return "mdi:trash-can" + else: + return "mdi:delete" @property - def name(self): + def name(self) -> str: """Return the name of the attribute sensor.""" return f"{self.coordinator.name} {self._bin_type} {self._attribute_type}" @property def state(self): """Return the state based on the attribute type.""" + bin_data = self.coordinator.data.get(self._bin_type) + if not bin_data: + return "Unknown" + if self._attribute_type == "Colour": - return self._color # Return the colour of the bin + return self._color + elif self._attribute_type == "Next Collection Human Readable": - return self.coordinator.data[ - self._bin_type - ] # Already formatted next collection + try: + collection_date = parser.parse(bin_data, dayfirst=True).date() + now = dt_util.now().date() + if collection_date == now: + return "Today" + elif collection_date == now + timedelta(days=1): + return "Tomorrow" + else: + days = (collection_date - now).days + day_label = "day" if days == 1 else "days" + return f"In {days} {day_label}" + except (ValueError, TypeError): + return "Invalid Date" + elif self._attribute_type == "Days Until Collection": - next_collection = parser.parse( - self.coordinator.data[self._bin_type], dayfirst=True - ).date() - return (next_collection - datetime.now().date()).days + try: + next_collection = parser.parse(bin_data, dayfirst=True).date() + return (next_collection - dt_util.now().date()).days + except (ValueError, TypeError): + return "Invalid Date" + elif self._attribute_type == "Bin Type": - return self._bin_type # Return the bin type for the Bin Type sensor + return self._bin_type + elif self._attribute_type == "Next Collection Date": - return self.coordinator.data[ - self._bin_type - ] # Return the raw next collection date + return bin_data + + else: + _LOGGER.warning(f"{LOG_PREFIX} Undefined attribute type: {self._attribute_type}") + return "Undefined" @property - def icon(self): - """Return the entity icon.""" + def icon(self) -> str: + """Return the icon for the attribute sensor.""" return self._icon @property - def color(self): - """Return the entity icon.""" + def color(self) -> str: + """Return the color associated with the attribute sensor.""" return self._color @property - def extra_state_attributes(self): - """Return extra attributes of the sensor.""" + def extra_state_attributes(self) -> dict: + """Return extra state attributes for the attribute sensor.""" return { STATE_ATTR_COLOUR: self._color, - STATE_ATTR_NEXT_COLLECTION: self.coordinator.data[ - self._bin_type - ], # Return the collection date + STATE_ATTR_NEXT_COLLECTION: self.coordinator.data.get(self._bin_type), } @property - def device_info(self): - """Return device information for grouping sensors.""" + def device_info(self) -> dict: + """Return device information for device registry.""" return { - "identifiers": { - (DOMAIN, self._device_id) - }, # Use the same device_id for all sensors of the same bin type + "identifiers": {(DOMAIN, self._device_id)}, "name": f"{self.coordinator.name} {self._bin_type}", "manufacturer": "UK Bin Collection", "model": "Bin Sensor", @@ -395,38 +520,60 @@ def device_info(self): } @property - def unique_id(self): - """Return a unique ID for the sensor.""" + def unique_id(self) -> str: + """Return a unique ID for the attribute sensor.""" return self._unique_id + @property + def available(self) -> bool: + """Return the availability of the attribute sensor.""" + return self.coordinator.last_update_success + class UKBinCollectionRawJSONSensor(CoordinatorEntity, SensorEntity): - """Sensor to hold the raw JSON data for bin collections.""" + """Sensor entity to hold the raw JSON data for bin collections.""" - def __init__(self, coordinator, unique_id, name) -> None: - """Initialize the raw JSON sensor.""" + def __init__( + self, + coordinator: HouseholdBinCoordinator, + unique_id: str, + name: str, + ) -> None: + """ + Initialize the raw JSON sensor. + + Args: + coordinator: Data coordinator instance. + unique_id: Unique identifier for the sensor. + name: Base name for the sensor. + """ super().__init__(coordinator) self._unique_id = unique_id self._name = name @property - def name(self): - """Return the name of the sensor.""" + def name(self) -> str: + """Return the name of the raw JSON sensor.""" return f"{self._name} Raw JSON" @property - def state(self): - """Return the state, which is the raw JSON data.""" - return json.dumps(self.coordinator.data) # Convert the raw dict to JSON string + def state(self) -> str: + """Return the raw JSON data as the state.""" + return json.dumps(self.coordinator.data) if self.coordinator.data else "{}" @property - def unique_id(self): - """Return a unique ID for the sensor.""" + def unique_id(self) -> str: + """Return a unique ID for the raw JSON sensor.""" return self._unique_id @property - def extra_state_attributes(self): - """Return extra attributes for the sensor.""" + def extra_state_attributes(self) -> dict: + """Return the raw JSON data as an attribute.""" return { - "raw_data": self.coordinator.data # Provide the raw data as an attribute + "raw_data": self.coordinator.data or {} } + + @property + def available(self) -> bool: + """Return the availability of the raw JSON sensor.""" + return self.coordinator.last_update_success diff --git a/custom_components/uk_bin_collection/strings.json b/custom_components/uk_bin_collection/strings.json index c8b6195473..7a99864100 100644 --- a/custom_components/uk_bin_collection/strings.json +++ b/custom_components/uk_bin_collection/strings.json @@ -6,7 +6,9 @@ "title": "Select the council", "data": { "name": "Location name", - "council": "Council" + "council": "Council", + "icon_color_mapping":"JSON to map Bin Type for Colour and Icon see: https://github.com/robbrad/UKBinCollectionData" + }, "description": "Please see [here](https://github.com/robbrad/UKBinCollectionData#requesting-your-council) if your council isn't listed" }, @@ -21,8 +23,7 @@ "usrn": "USRN (Unique Street Reference Number)", "web_driver": "To run on a remote Selenium Server add the Selenium Server URL", "headless": "Run Selenium in headless mode (recommended)", - "local_browser": "Don't run remote Selenium server, use local install of Chrome instead", - "icon_color_mapping":"JSON to map Bin Type for Colour and Icon see: https://github.com/robbrad/UKBinCollectionData", + "local_browser": "Don't run on remote Selenium server, use local install of Chrome instead", "submit": "Submit" }, "description": "Please refer to your councils [wiki](https://github.com/robbrad/UKBinCollectionData/wiki/Councils) entry for details on what to enter" @@ -38,7 +39,7 @@ "usrn": "USRN (Unique Street Reference Number)", "web_driver": "To run on a remote Selenium Server add the Selenium Server URL", "headless": "Run Selenium in headless mode (recommended)", - "local_browser": "Don't run remote Selenium server, use local install of Chrome instead", + "local_browser": "Don't run on remote Selenium server, use local install of Chrome instead", "icon_color_mapping":"JSON to map Bin Type for Colour and Icon see: https://github.com/robbrad/UKBinCollectionData", "submit": "Submit" }, diff --git a/custom_components/uk_bin_collection/tests/__init__.py b/custom_components/uk_bin_collection/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/custom_components/uk_bin_collection/tests/test_config_flow.py b/custom_components/uk_bin_collection/tests/test_config_flow.py new file mode 100644 index 0000000000..7f61840716 --- /dev/null +++ b/custom_components/uk_bin_collection/tests/test_config_flow.py @@ -0,0 +1,663 @@ +# test_config_flow.py + +"""Test UkBinCollection config flow.""" +from unittest.mock import patch +from homeassistant import config_entries, data_entry_flow +from homeassistant.const import CONF_NAME, CONF_URL +from pytest_homeassistant_custom_component.common import MockConfigEntry +import pytest +import voluptuous as vol + +from custom_components.uk_bin_collection.config_flow import ( + UkBinCollectionConfigFlow, +) +from custom_components.uk_bin_collection.const import DOMAIN + + +# Fixture to enable custom integrations +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + yield + +# Mock council data representing different scenarios +MOCK_COUNCILS_DATA = { + "CouncilWithoutURL": { + "wiki_name": "Council without URL", + "skip_get_url": True, + # Do not include 'custom_component_show_url_field' + # Other necessary fields + "uprn": True, + "url": "https://example.com/council_without_url", + }, + "CouncilWithUSRN": { + "wiki_name": "Council with USRN", + "usrn": True, + }, + "CouncilWithUPRN": { + "wiki_name": "Council with UPRN", + "uprn": True, + }, + "CouncilWithPostcodeNumber": { + "wiki_name": "Council with Postcode and Number", + "postcode": True, + "house_number": True, + }, + "CouncilWithWebDriver": { + "wiki_name": "Council with Web Driver", + "web_driver": True, + }, + "CouncilSkippingURL": { + "wiki_name": "Council skipping URL", + "skip_get_url": True, + "url": "https://council.example.com", + }, + "CouncilCustomURLField": { + "wiki_name": "Council with Custom URL Field", + "custom_component_show_url_field": True, + }, + # Add more mock councils as needed to cover different scenarios +} + +# Helper function to initiate the config flow and proceed through steps +async def proceed_through_config_flow( + hass, flow, user_input_initial, user_input_council +): + # Start the flow and complete the `user` step + result = await flow.async_step_user(user_input=user_input_initial) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "council" + + # Complete the `council` step + result = await flow.async_step_council(user_input=user_input_council) + + return result + +async def test_config_flow_with_uprn(hass): + """Test config flow for a council requiring UPRN.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "Test Name", + "council": "Council with UPRN", + } + user_input_council = { + "uprn": "1234567890", + "timeout": 60, + } + + result = await proceed_through_config_flow( + hass, flow, user_input_initial, user_input_council + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Name" + assert result["data"] == { + "name": "Test Name", + "council": "CouncilWithUPRN", + "uprn": "1234567890", + "timeout": 60, + } + +async def test_config_flow_with_postcode_and_number(hass): + """Test config flow for a council requiring postcode and house number.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "Test Name", + "council": "Council with Postcode and Number", + } + user_input_council = { + "postcode": "AB1 2CD", + "number": "42", + "timeout": 60, + } + + result = await proceed_through_config_flow( + hass, flow, user_input_initial, user_input_council + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Name" + assert result["data"] == { + "name": "Test Name", + "council": "CouncilWithPostcodeNumber", + "postcode": "AB1 2CD", + "number": "42", + "timeout": 60, + } + +async def test_config_flow_with_web_driver(hass): + """Test config flow for a council requiring web driver.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "Test Name", + "council": "Council with Web Driver", + } + user_input_council = { + "web_driver": "/path/to/webdriver", + "headless": True, + "local_browser": False, + "timeout": 60, + } + + result = await proceed_through_config_flow( + hass, flow, user_input_initial, user_input_council + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Name" + assert result["data"] == { + "name": "Test Name", + "council": "CouncilWithWebDriver", + "web_driver": "/path/to/webdriver", + "headless": True, + "local_browser": False, + "timeout": 60, + } + +async def test_config_flow_skipping_url(hass): + """Test config flow for a council that skips URL input.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "Test Name", + "council": "Council skipping URL", + } + user_input_council = { + "timeout": 60, + } + + result = await proceed_through_config_flow( + hass, flow, user_input_initial, user_input_council + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Name" + assert result["data"] == { + "name": "Test Name", + "council": "CouncilSkippingURL", + "skip_get_url": True, + "url": "https://council.example.com", + "timeout": 60, + } + +async def test_config_flow_with_custom_url_field(hass): + """Test config flow for a council with custom URL field.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "Test Name", + "council": "Council with Custom URL Field", + } + user_input_council = { + "url": "https://custom-url.example.com", + "timeout": 60, + } + + result = await proceed_through_config_flow( + hass, flow, user_input_initial, user_input_council + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Name" + assert result["data"] == { + "name": "Test Name", + "council": "CouncilCustomURLField", + "url": "https://custom-url.example.com", + "timeout": 60, + } + +async def test_config_flow_missing_name(hass): + """Test config flow when name is missing.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "", # Missing name + "council": "Council with UPRN", + } + + result = await flow.async_step_user(user_input=user_input_initial) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "name"} + +async def test_config_flow_invalid_icon_color_mapping(hass): + """Test config flow with invalid icon_color_mapping JSON.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "Test Name", + "council": "Council with UPRN", + "icon_color_mapping": "invalid json", # Invalid JSON + } + + result = await flow.async_step_user(user_input=user_input_initial) + + # Should return to the user step with an error + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"icon_color_mapping": "invalid_json"} + +async def test_config_flow_with_usrn(hass): + """Test config flow for a council requiring USRN.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "Test Name", + "council": "Council with USRN", + } + user_input_council = { + "usrn": "9876543210", + "timeout": 60, + } + + result = await proceed_through_config_flow( + hass, flow, user_input_initial, user_input_council + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Name" + assert result["data"] == { + "name": "Test Name", + "council": "CouncilWithUSRN", + "usrn": "9876543210", + "timeout": 60, + } + +async def test_reconfigure_flow(hass): + """Test reconfiguration of an existing integration.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + # Create an existing entry + existing_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "name": "Existing Entry", + "council": "CouncilWithUPRN", + "uprn": "1234567890", + "timeout": 60, + }, + ) + existing_entry.add_to_hass(hass) + + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + # Set the context to reconfigure the existing entry + flow.context = {"source": "reconfigure", "entry_id": existing_entry.entry_id} + + # Start the reconfiguration flow + result = await flow.async_step_reconfigure() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reconfigure_confirm" + + # Provide updated data + user_input = { + "name": "Updated Entry", + "council": "Council with UPRN", + "uprn": "0987654321", + "timeout": 120, + } + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload", + return_value=True, + ) as mock_reload: + result = await flow.async_step_reconfigure_confirm(user_input=user_input) + mock_reload.assert_called_once_with(existing_entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "Reconfigure Successful" + + # Verify that the existing entry has been updated + assert existing_entry.data["name"] == "Updated Entry" + assert existing_entry.data["uprn"] == "0987654321" + assert existing_entry.data["timeout"] == 120 + + +async def get_councils_json(self) -> object: + """Returns an object of supported councils and their required fields.""" + url = "https://raw.githubusercontent.com/robbrad/UKBinCollectionData/0.104.0/uk_bin_collection/tests/input.json" + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + data_text = await response.text() + return json.loads(data_text) + except Exception as e: + _LOGGER.error("Failed to fetch councils data: %s", e) + return {} + + +async def test_get_councils_json_failure(hass): + """Test handling when get_councils_json fails.""" + with patch( + "aiohttp.ClientSession", + autospec=True, + ) as mock_session_cls: + # Configure the mock session to simulate a network error + mock_session = mock_session_cls.return_value.__aenter__.return_value + mock_session.get.side_effect = Exception("Network error") + + # Initialize the flow + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + # Start the flow using hass.config_entries.flow.async_init + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # The flow should abort due to council data being unavailable + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "council_data_unavailable" + + + +async def test_async_step_init(hass): + """Test the initial step of the flow.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.async_step_user", + return_value=data_entry_flow.FlowResultType.FORM, + ) as mock_async_step_user: + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + result = await flow.async_step_init(user_input=None) + mock_async_step_user.assert_called_once_with(user_input=None) + assert result == data_entry_flow.FlowResultType.FORM + +async def test_config_flow_user_input_none(hass): + """Test config flow when user_input is None.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + +async def test_config_flow_with_optional_fields(hass): + """Test config flow with optional fields provided.""" + # Assume 'CouncilWithOptionalFields' requires 'uprn' and has optional 'web_driver' + MOCK_COUNCILS_DATA['CouncilWithOptionalFields'] = { + "wiki_name": "Council with Optional Fields", + "uprn": True, + "web_driver": True, + } + + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "Test Name", + "council": "Council with Optional Fields", + } + user_input_council = { + "uprn": "1234567890", + "web_driver": "/path/to/webdriver", + "headless": True, + "local_browser": False, + "timeout": 60, + } + + result = await proceed_through_config_flow( + hass, flow, user_input_initial, user_input_council + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Name" + assert result["data"] == { + "name": "Test Name", + "council": "CouncilWithOptionalFields", + "uprn": "1234567890", + "web_driver": "/path/to/webdriver", + "headless": True, + "local_browser": False, + "timeout": 60, + } + +async def test_get_councils_json_session_creation_failure(hass): + """Test handling when creating aiohttp ClientSession fails.""" + with patch( + "aiohttp.ClientSession", + side_effect=Exception("Failed to create session"), + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + # Start the flow using hass.config_entries.flow.async_init + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # The flow should abort due to council data being unavailable + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "council_data_unavailable" + +async def test_config_flow_council_without_url(hass): + """Test config flow for a council where 'url' field should not be included.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "Test Name", + "council": "Council without URL", + } + user_input_council = { + "uprn": "1234567890", + "timeout": 60, + } + + # Start the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Provide initial user input + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input_initial + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "council" + + # Check that 'url' is not in the schema + schema_fields = result["data_schema"].schema + assert "url" not in schema_fields + + # Provide council-specific input + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input_council + ) + + # Flow should proceed to create entry + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Name" + assert result["data"] == { + "name": "Test Name", + "council": "CouncilWithoutURL", + "uprn": "1234567890", + "timeout": 60, + "skip_get_url": True, + "url": "https://example.com/council_without_url", + } +async def test_config_flow_missing_council(hass): + """Test config flow when council is missing.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + user_input_initial = { + "name": "Test Name", + "council": "", # Missing council + } + + result = await flow.async_step_user(user_input=user_input_initial) + + # Should return to the user step with an error + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "council"} + +async def test_reconfigure_flow_with_errors(hass): + """Test reconfiguration with invalid input.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + # Create an existing entry + existing_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "name": "Existing Entry", + "council": "CouncilWithUPRN", + "uprn": "1234567890", + "timeout": 60, + }, + ) + existing_entry.add_to_hass(hass) + + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + # Set the context to reconfigure the existing entry + flow.context = {"source": "reconfigure", "entry_id": existing_entry.entry_id} + + # Start the reconfiguration flow + result = await flow.async_step_reconfigure() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reconfigure_confirm" + + # Provide invalid data (e.g., invalid JSON for icon_color_mapping) + user_input = { + "name": "Updated Entry", + "council": "Council with UPRN", + "uprn": "0987654321", + "icon_color_mapping": "invalid json", + "timeout": 60, + } + + result = await flow.async_step_reconfigure_confirm(user_input=user_input) + + # Should return to the reconfigure_confirm step with an error + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reconfigure_confirm" + assert result["errors"] == {"icon_color_mapping": "invalid_json"} + +async def test_reconfigure_flow_entry_missing(hass): + """Test reconfiguration when the config entry is missing.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + # Set the context with an invalid entry_id + flow.context = {"source": "reconfigure", "entry_id": "invalid_entry_id"} + + # Start the reconfiguration flow + result = await flow.async_step_reconfigure() + + # Flow should abort due to missing config entry + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reconfigure_failed" + +async def test_reconfigure_flow_no_user_input(hass): + """Test reconfiguration when user_input is None.""" + with patch( + "custom_components.uk_bin_collection.config_flow.UkBinCollectionConfigFlow.get_councils_json", + return_value=MOCK_COUNCILS_DATA, + ): + # Create an existing entry + existing_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "name": "Existing Entry", + "council": "CouncilWithUPRN", + "uprn": "1234567890", + "timeout": 60, + }, + ) + existing_entry.add_to_hass(hass) + + flow = UkBinCollectionConfigFlow() + flow.hass = hass + + # Set the context to reconfigure the existing entry + flow.context = {"source": "reconfigure", "entry_id": existing_entry.entry_id} + + # Start the reconfiguration flow + result = await flow.async_step_reconfigure() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reconfigure_confirm" + + # Proceed without user input + result = await flow.async_step_reconfigure_confirm(user_input=None) + + # Should show the form again + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reconfigure_confirm" diff --git a/custom_components/uk_bin_collection/tests/test_sensor.py b/custom_components/uk_bin_collection/tests/test_sensor.py new file mode 100644 index 0000000000..9943347d76 --- /dev/null +++ b/custom_components/uk_bin_collection/tests/test_sensor.py @@ -0,0 +1,1199 @@ +import json +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch + +import pytest +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import async_get_current_platform +from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.uk_bin_collection.const import DOMAIN +from custom_components.uk_bin_collection.sensor import ( + UKBinCollectionAttributeSensor, + UKBinCollectionDataSensor, + UKBinCollectionRawJSONSensor, + async_setup_entry, + get_latest_collection_info, +) + +today = datetime.now().date() +MOCK_BIN_COLLECTION_DATA = { + "bins": [ + {"type": "General Waste", "collectionDate": "15/10/2023"}, + {"type": "Recycling", "collectionDate": "16/10/2023"}, + {"type": "Garden Waste", "collectionDate": "17/10/2023"}, + ] +} + +MOCK_PROCESSED_DATA = { + "General Waste": "15/10/2023", + "Recycling": "16/10/2023", + "Garden Waste": "17/10/2023", +} + + +@pytest.fixture(autouse=True) +def expected_lingering_timers(): + """Allow lingering timers in this test.""" + return True + + +@pytest.fixture(autouse=True) +def mock_dt_now(): + with patch( + "homeassistant.util.dt.now", + return_value=datetime(2023, 10, 14, tzinfo=dt_util.DEFAULT_TIME_ZONE), + ): + yield + + +def test_get_latest_collection_info(freezer): + """Test processing of bin collection data.""" + freezer.move_to("2023-10-14") + processed_data = get_latest_collection_info(MOCK_BIN_COLLECTION_DATA) + assert processed_data == MOCK_PROCESSED_DATA + + +@pytest.fixture +def mock_config_entry(): + """Create a mock ConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Test Entry", + data={ + "name": "Test Name", + "council": "Test Council", + "url": "https://example.com", + "timeout": 60, + "icon_color_mapping": "{}", + }, + entry_id="test", + unique_id="test_unique_id", + ) + + +def test_get_latest_collection_info(freezer): + """Test processing of bin collection data.""" + freezer.move_to("2023-10-14") + processed_data = get_latest_collection_info(MOCK_BIN_COLLECTION_DATA) + assert processed_data == MOCK_PROCESSED_DATA + + +async def test_async_setup_entry(hass, mock_config_entry): + """Test setting up the sensor platform.""" + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + # Mock async_add_entities + async_add_entities = MagicMock() + + await async_setup_entry(hass, mock_config_entry, async_add_entities) + + # Verify that entities were added + assert async_add_entities.call_count == 1 + entities = async_add_entities.call_args[0][0] + assert len(entities) == 19 # 5 entities per bin type + 1 raw JSON sensor + + +async def test_coordinator_fetch(hass, freezer, mock_config_entry): + """Test the data fetch by the coordinator.""" + freezer.move_to("2023-10-14") + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + # Create the coordinator + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + # Verify data + assert coordinator.data == MOCK_PROCESSED_DATA + + +async def test_bin_sensor(hass, mock_config_entry): + """Test the main bin sensor.""" + # Set up the coordinator + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + # Create a bin sensor + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + + # Access properties + assert sensor.name == "Test Name General Waste" + assert sensor.unique_id == "test_general_waste" + if sensor._days == 1: + assert sensor.state == "Tomorrow" + elif sensor._days == 0: + assert sensor.state == "Today" + else: + assert sensor.state == f"In {sensor._days} days" + assert sensor.icon == "mdi:trash-can" + assert sensor.extra_state_attributes == { + "colour": "black", + "next_collection": "15/10/2023", + "days": sensor._days, + } + + +async def test_attribute_sensor(hass, mock_config_entry): + """Test the attribute sensor.""" + # Set up the coordinator + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, + mock_app_instance, + "Test Name", + mock_config_entry, + timeout=60, + ) + + await coordinator.async_config_entry_first_refresh() + + # Create an attribute sensor + sensor = UKBinCollectionAttributeSensor( + coordinator, + "General Waste", + "test_general_waste_colour", + "Colour", + "test_general_waste", + "{}", + ) + + # Access properties + assert sensor.name == "Test Name General Waste Colour" + assert sensor.unique_id == "test_general_waste_colour" + assert sensor.state == "black" + assert sensor.icon == "mdi:trash-can" + assert sensor.extra_state_attributes == { + "colour": "black", + "next_collection": "15/10/2023", + } + + +async def test_raw_json_sensor(hass, mock_config_entry): + """Test the raw JSON sensor.""" + # Set up the coordinator + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + # Create the raw JSON sensor + sensor = UKBinCollectionRawJSONSensor(coordinator, "test_raw_json", "Test Name") + + # Access properties + assert sensor.name == "Test Name Raw JSON" + assert sensor.unique_id == "test_raw_json" + assert sensor.state == json.dumps(MOCK_PROCESSED_DATA) + assert sensor.extra_state_attributes == {"raw_data": MOCK_PROCESSED_DATA} + + +async def test_coordinator_fetch_failure(hass, mock_config_entry): + """Test handling when data fetch fails.""" + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + # Simulate an exception during run + mock_app_instance.run.side_effect = Exception("Network error") + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + with pytest.raises(Exception): + await coordinator.async_config_entry_first_refresh() + + assert coordinator.last_update_success is False + + +def test_get_latest_collection_info_empty(): + """Test processing when data is empty.""" + processed_data = get_latest_collection_info({"bins": []}) + assert processed_data == {} + + +def test_get_latest_collection_info_past_dates(freezer): + """Test processing when all dates are in the past.""" + freezer.move_to("2023-10-14") + past_date = (datetime.now() - timedelta(days=1)).strftime("%d/%m/%Y") + data = { + "bins": [ + {"type": "General Waste", "collectionDate": past_date}, + ] + } + processed_data = get_latest_collection_info(data) + assert processed_data == {} # No future dates + + +async def test_bin_sensor_custom_icon_color(hass, mock_config_entry): + """Test bin sensor with custom icon and color.""" + icon_color_mapping = json.dumps( + {"General Waste": {"icon": "mdi:delete", "color": "green"}} + ) + + # Set up the coordinator + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + # Create a bin sensor + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", icon_color_mapping + ) + + # Access properties + assert sensor.icon == "mdi:delete" + assert sensor.extra_state_attributes["colour"] == "green" + + +async def test_bin_sensor_today_collection(hass, freezer, mock_config_entry): + """Test bin sensor when collection is today.""" + freezer.move_to("2023-10-14") + today_date = datetime.now().strftime("%d/%m/%Y") + data = { + "bins": [ + {"type": "General Waste", "collectionDate": today_date}, + ] + } + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(data) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + # Create a bin sensor + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + + # Access properties + assert sensor.state == "Today" + + +async def test_bin_sensor_tomorrow_collection(hass, freezer, mock_config_entry): + """Test bin sensor when collection is tomorrow.""" + freezer.move_to("2023-10-14") + tomorrow_date = (datetime.now() + timedelta(days=1)).strftime("%d/%m/%Y") + data = { + "bins": [ + {"type": "General Waste", "collectionDate": tomorrow_date}, + ] + } + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(data) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + # Create a bin sensor + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + + # Access properties + assert sensor.state == "Tomorrow" + + +async def test_sensor_coordinator_update( + hass, freezer, mock_config_entry, enable_custom_integrations +): + """Test that sensor updates when coordinator data changes.""" + freezer.move_to("2023-10-14") + + # Add the config entry to hass + mock_config_entry.add_to_hass(hass) + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + # Set up the config entry + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Find the sensor we want + sensor_entity_id = "sensor.test_name_general_waste" + state = hass.states.get(sensor_entity_id) + assert state is not None + + initial_state = state.state + + # Update the coordinator with new data + new_data = { + "bins": [ + {"type": "General Waste", "collectionDate": "20/10/2023"}, + ] + } + mock_app_instance.run.return_value = json.dumps(new_data) + + # Request a refresh + coordinator = hass.data[DOMAIN][mock_config_entry.entry_id] + await coordinator.async_request_refresh() + await hass.async_block_till_done() + + # Get the new state + state = hass.states.get(sensor_entity_id) + assert state.state != initial_state + assert state.state == "In 6 days" + + # Stop the coordinator to avoid lingering timers + await coordinator.async_shutdown() + await hass.async_block_till_done() + + +async def test_unload_entry(hass, mock_config_entry): + """Test unloading the config entry.""" + with patch("custom_components.uk_bin_collection.sensor.UKBinCollectionApp"), patch( + "custom_components.uk_bin_collection.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.loader.async_get_integration", + return_value=MagicMock(), + ): + # Add the config entry to hass + mock_config_entry.add_to_hass(hass) + + # Set up the entry + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Unload the entry + result = await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is True + assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + + +def test_get_latest_collection_info_missing_type(freezer): + """Test processing when a bin entry is missing the 'type' field.""" + freezer.move_to("2023-10-14") + data = { + "bins": [ + {"collectionDate": "15/10/2023"}, + ] + } + processed_data = get_latest_collection_info(data) + assert processed_data == {} # Should ignore entries without 'type' + + +def test_get_latest_collection_info_missing_collection_date(freezer): + """Test processing when a bin entry is missing the 'collectionDate' field.""" + freezer.move_to("2023-10-14") + data = { + "bins": [ + {"type": "General Waste"}, + ] + } + processed_data = get_latest_collection_info(data) + assert processed_data == {} # Should ignore entries without 'collectionDate' + + +def test_get_latest_collection_info_malformed_date(freezer): + """Test processing when 'collectionDate' is malformed.""" + freezer.move_to("2023-10-14") + data = { + "bins": [ + { + "type": "General Waste", + "collectionDate": "2023-15-10", + }, # Incorrect format + ] + } + processed_data = get_latest_collection_info(data) + assert processed_data == {} # Should ignore entries with invalid date format + + +def test_get_latest_collection_info_multiple_bins_same_date(freezer): + """Test processing with multiple bin types having the same collection date.""" + freezer.move_to("2023-10-14") + data = { + "bins": [ + {"type": "General Waste", "collectionDate": "15/10/2023"}, + {"type": "Recycling", "collectionDate": "15/10/2023"}, + ] + } + expected = { + "General Waste": "15/10/2023", + "Recycling": "15/10/2023", + } + processed_data = get_latest_collection_info(data) + assert processed_data == expected + + +async def test_bin_sensor_partial_custom_icon_color(hass, mock_config_entry): + """Test bin sensor with partial custom icon and color mappings.""" + icon_color_mapping = json.dumps( + {"General Waste": {"icon": "mdi:delete", "color": "green"}} + ) + + # Modify MOCK_BIN_COLLECTION_DATA to include another bin type without custom mapping + custom_data = { + "bins": [ + {"type": "General Waste", "collectionDate": "15/10/2023"}, + {"type": "Recycling", "collectionDate": "16/10/2023"}, + ] + } + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(custom_data) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + # Create sensors for both bin types + sensor_general = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", icon_color_mapping + ) + sensor_recycling = UKBinCollectionDataSensor( + coordinator, "Recycling", "test_recycling", icon_color_mapping + ) + + # Check custom mapping for General Waste + assert sensor_general.icon == "mdi:delete" + assert sensor_general.extra_state_attributes["colour"] == "green" + + # Check default mapping for Recycling + assert sensor_recycling.icon == "mdi:recycle" + assert sensor_recycling.extra_state_attributes["colour"] == "black" + + +async def test_async_setup_entry_invalid_icon_color_mapping(hass): + """Test setup with invalid JSON in icon_color_mapping.""" + # Create a new MockConfigEntry with invalid JSON for icon_color_mapping + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Test Entry", + data={ + "name": "Test Name", + "council": "Test Council", + "url": "https://example.com", + "timeout": 60, + "icon_color_mapping": "{invalid_json}", # Invalid JSON + }, + entry_id="test_invalid_icon_color", + unique_id="test_invalid_icon_color_unique_id", + ) + + # Add the entry to Home Assistant + mock_config_entry.add_to_hass(hass) + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + async_add_entities = MagicMock() + + await async_setup_entry(hass, mock_config_entry, async_add_entities) + await hass.async_block_till_done() + + # Verify that entities were added despite invalid JSON + assert async_add_entities.call_count == 1 + entities = async_add_entities.call_args[0][0] + assert len(entities) == 19 # 5 entities per bin type + 1 raw JSON sensor + + # Check that default icon and color are used + data_sensor = next( + ( + e + for e in entities + if isinstance(e, UKBinCollectionDataSensor) + and e._bin_type == "General Waste" + ), + None, + ) + assert data_sensor is not None + assert data_sensor.icon == "mdi:trash-can" # Default icon + assert data_sensor.extra_state_attributes["colour"] == "black" # Default color + + +async def test_sensor_available_when_data_present(hass, mock_config_entry): + """Test that sensor is available when data is present.""" + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + assert sensor.available is True + + +async def test_sensor_unavailable_when_data_missing(hass, mock_config_entry): + """Test that sensor is unavailable when data is missing.""" + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + # Return empty data + mock_app_instance.run.return_value = json.dumps({"bins": []}) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + assert sensor.available is False + + +def test_unique_id_uniqueness(hass, mock_config_entry): + """Test that each sensor has a unique ID.""" + coordinator = MagicMock() + coordinator.name = "Test Name" + coordinator.data = MOCK_PROCESSED_DATA + + sensor1 = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + sensor2 = UKBinCollectionDataSensor( + coordinator, "Recycling", "test_recycling", "{}" + ) + + assert sensor1.unique_id == "test_general_waste" + assert sensor2.unique_id == "test_recycling" + assert sensor1.unique_id != sensor2.unique_id + + +@pytest.fixture +def mock_dt_now_different_timezone(): + """Mock datetime.now with a different timezone.""" + with patch( + "homeassistant.util.dt.now", + return_value=datetime(2023, 10, 14, 12, 0, tzinfo=dt_util.UTC), + ): + yield + + +async def test_bin_sensor_with_different_timezone( + hass, mock_config_entry, mock_dt_now_different_timezone +): + """Test bin sensor with different timezone settings.""" + data = { + "bins": [ + {"type": "General Waste", "collectionDate": "15/10/2023"}, + ] + } + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(data) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_refresh() + await hass.async_block_till_done() + + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + + # Adjust expectation based on the date + assert sensor.state == "Tomorrow" + + +async def test_bin_sensor_invalid_date_format(hass, mock_config_entry): + """Test bin sensor with invalid date format.""" + data = { + "bins": [ + {"type": "General Waste", "collectionDate": "2023-10-15"}, # Invalid format + ] + } + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(data) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + + assert sensor.state == "Unknown" + assert sensor.available is False + + +@pytest.mark.asyncio +async def test_async_setup_entry_missing_required_fields(hass): + """Test setup with missing required configuration fields.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Test Entry", + data={ + # "name" is missing, should default to "UK Bin Collection" + "council": "Test Council", + "url": "https://example.com", + "timeout": 60, + "icon_color_mapping": "{}", + }, + entry_id="test", + unique_id="test_unique_id", + ) + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + async_add_entities = MagicMock() + + await async_setup_entry(hass, mock_config_entry, async_add_entities) + await hass.async_block_till_done() + + # Assert that async_add_entities was called once + assert ( + async_add_entities.call_count == 1 + ), f"Expected async_add_entities to be called once, got {async_add_entities.call_count}" + + # Retrieve the list of entities passed to async_add_entities + entities = async_add_entities.call_args[0][0] + + # Calculate expected number of entities + # For each bin type, 6 entities are created (1 data sensor + 5 attribute sensors) + # Plus 1 raw JSON sensor + expected_bin_types = len(MOCK_BIN_COLLECTION_DATA["bins"]) + expected_entities = expected_bin_types * 6 + 1 # 3*6 +1 = 19 + assert ( + len(entities) == expected_entities + ), f"Expected {expected_entities} entities, got {len(entities)}" + + # Check that a specific data sensor exists + data_sensor = next( + ( + e + for e in entities + if isinstance(e, UKBinCollectionDataSensor) + and e._bin_type == "General Waste" + ), + None, + ) + assert ( + data_sensor is not None + ), "UKBinCollectionDataSensor for 'General Waste' not found" + + # Optionally, verify that the raw JSON sensor is present + raw_json_sensor = next( + ( + e + for e in entities + if hasattr(e, "name") and e.name == "UK Bin Collection Raw JSON" + ), + None, + ) + assert raw_json_sensor is not None, "UKBinCollectionRawJSONSensor not found" + + +# test_sensor.py +async def test_async_setup_entry_invalid_config_types(hass): + """Test setup with invalid data types in configuration.""" + # Create a new MockConfigEntry with invalid timeout type + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Test Entry", + data={ + "name": "Test Name", + "council": "Test Council", + "url": "https://example.com", + "timeout": "sixty", # Should be an integer + "icon_color_mapping": "{}", + }, + entry_id="test_invalid_config", + unique_id="test_invalid_config_unique_id", + ) + + mock_config_entry.add_to_hass(hass) + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + async_add_entities = MagicMock() + + await async_setup_entry(hass, mock_config_entry, async_add_entities) + await hass.async_block_till_done() + + # Verify that async_add_entities was called despite invalid config + assert async_add_entities.call_count == 1 + + # Optionally, verify that a warning was logged about invalid timeout + + +async def test_coordinator_custom_update_interval(hass, mock_config_entry): + """Test coordinator with a custom update interval.""" + custom_update_interval = timedelta(hours=6) + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + coordinator.update_interval = custom_update_interval + + await coordinator.async_config_entry_first_refresh() + + assert coordinator.update_interval == custom_update_interval + assert coordinator.data == MOCK_PROCESSED_DATA + + +async def test_coordinator_custom_timeout(hass, mock_config_entry): + """Test coordinator with a custom timeout.""" + custom_timeout = 30 + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, + mock_app_instance, + "Test Name", + mock_config_entry, + timeout=custom_timeout, + ) + + assert coordinator.timeout == custom_timeout + + await coordinator.async_config_entry_first_refresh() + + assert coordinator.data == MOCK_PROCESSED_DATA + + +async def test_some_bins_missing_data(hass, mock_config_entry): + """Test sensors when some bin types have missing data.""" + data = { + "bins": [ + {"type": "General Waste", "collectionDate": "15/10/2023"}, + # "Recycling" data is missing + ] + } + expected = { + "General Waste": "15/10/2023", + } + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(data) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + # Create sensors for both bin types + sensor_general = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + sensor_recycling = UKBinCollectionDataSensor( + coordinator, "Recycling", "test_recycling", "{}" + ) + + # Check General Waste sensor + assert sensor_general.state == "Tomorrow" # Change from "In 1 days" to "Tomorrow" + # Check Recycling sensor which has missing data + assert sensor_recycling.state == "Unknown" + assert sensor_recycling.available is False + + +async def test_raw_json_sensor_invalid_data(hass, mock_config_entry): + """Test raw JSON sensor with invalid data.""" + invalid_data = "Invalid JSON String" + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = invalid_data # Not a valid JSON + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + # Attempt to refresh coordinator, which should NOT raise UpdateFailed + await coordinator.async_refresh() + + # Verify that last_update_success is False + assert coordinator.last_update_success is False + + # Create the raw JSON sensor + sensor = UKBinCollectionRawJSONSensor(coordinator, "test_raw_json", "Test Name") + + # Since data fetch failed, sensor.state should reflect the failure + assert sensor.state == json.dumps({}) + assert sensor.extra_state_attributes == {"raw_data": {}} + assert sensor.available is False + + +async def test_coordinator_shutdown(hass, mock_config_entry): + """Test that coordinator shuts down properly.""" + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_refresh() + await hass.async_block_till_done() + + # Shutdown the coordinator + await coordinator.async_shutdown() + await hass.async_block_till_done() + + # Since 'update_task' doesn't exist, we can verify that no further updates occur + mock_app_instance.run.assert_called_once() + + +def test_sensor_device_info(hass, mock_config_entry): + """Test that sensors report correct device information.""" + coordinator = MagicMock() + coordinator.name = "Test Name" + coordinator.data = MOCK_PROCESSED_DATA + + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + + expected_device_info = { + "identifiers": {(DOMAIN, "test_general_waste")}, + "name": "Test Name General Waste", + "manufacturer": "UK Bin Collection", + "model": "Bin Sensor", + "sw_version": "1.0", + } + assert sensor.device_info == expected_device_info + + +async def test_entity_registry_registration(hass, mock_config_entry): + """Test that sensors are correctly registered in the entity registry.""" + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app, patch( + "custom_components.uk_bin_collection.async_setup_entry", return_value=True + ): + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + # Add the config entry to hass + mock_config_entry.add_to_hass(hass) + + # Set up the config entry + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Access the entity registry + registry = er.async_get(hass) + + # Check that entities are registered + for entity_id in registry.entities: + entity = registry.entities[entity_id] + if entity.platform == DOMAIN: + assert entity.unique_id is not None + + +async def test_entity_registry_registration_with_platform(hass, mock_config_entry): + """Test that sensors are correctly registered in the entity registry.""" + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app, patch( + "custom_components.uk_bin_collection.async_setup_entry", return_value=True + ): + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + # Add the config entry to hass + mock_config_entry.add_to_hass(hass) + + # Set up the config entry + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Access the entity registry + registry = er.async_get(hass) + + # Check that entities are registered + for entity_id in registry.entities: + entity = registry.entities[entity_id] + if entity.platform == DOMAIN: + assert entity.unique_id is not None + + +def test_get_latest_collection_info_duplicate_bin_types(freezer): + """Test processing when duplicate bin types are present.""" + freezer.move_to("2023-10-14") + data = { + "bins": [ + {"type": "General Waste", "collectionDate": "15/10/2023"}, + {"type": "General Waste", "collectionDate": "16/10/2023"}, # Later date + ] + } + expected = { + "General Waste": "15/10/2023", # Should take the earliest future date + } + processed_data = get_latest_collection_info(data) + assert processed_data == expected + + +async def test_bin_sensor_negative_days(hass, mock_config_entry, freezer): + """Test bin sensor when 'days' until collection is negative.""" + freezer.move_to("2023-10-14") + past_date = (datetime.now() - timedelta(days=1)).strftime("%d/%m/%Y") + data = { + "bins": [ + {"type": "General Waste", "collectionDate": past_date}, + ] + } + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(data) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + + assert sensor.state == "Unknown" + assert sensor.available is False + + +async def test_coordinator_last_update_success(hass, mock_config_entry): + """Test that coordinator.last_update_success reflects the update status.""" + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + + # First successful run + mock_app_instance.run.return_value = json.dumps(MOCK_BIN_COLLECTION_DATA) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + assert coordinator.last_update_success is True + + # Simulate a failed run + mock_app_instance.run.side_effect = Exception("Network error") + + # Attempt to refresh again + await coordinator.async_refresh() + assert coordinator.last_update_success is False + + +async def test_sensor_attributes_with_none_values(hass, mock_config_entry): + """Test sensor attributes when some values are None.""" + data = { + "bins": [ + {"type": "General Waste", "collectionDate": None}, + ] + } + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(data) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", "{}" + ) + + assert sensor.state == "Unknown" + assert sensor.extra_state_attributes["next_collection"] is None + assert sensor.extra_state_attributes["days"] is None + assert sensor.available is False + + +async def test_sensor_color_property_missing(hass, mock_config_entry): + """Test sensor's color property when color is missing.""" + icon_color_mapping = json.dumps( + {"General Waste": {"icon": "mdi:delete"}} # Color is missing + ) + + data = { + "bins": [ + {"type": "General Waste", "collectionDate": "15/10/2023"}, + ] + } + + with patch( + "custom_components.uk_bin_collection.sensor.UKBinCollectionApp" + ) as mock_app: + mock_app_instance = mock_app.return_value + mock_app_instance.run.return_value = json.dumps(data) + + from custom_components.uk_bin_collection.sensor import HouseholdBinCoordinator + + coordinator = HouseholdBinCoordinator( + hass, mock_app_instance, "Test Name", mock_config_entry, timeout=60 + ) + + await coordinator.async_config_entry_first_refresh() + + sensor = UKBinCollectionDataSensor( + coordinator, "General Waste", "test_general_waste", icon_color_mapping + ) + + # Color should default to "black" + assert sensor.color == "black" diff --git a/custom_components/uk_bin_collection/translations/cy.json b/custom_components/uk_bin_collection/translations/cy.json index 6a521162bb..d62a27fdfc 100644 --- a/custom_components/uk_bin_collection/translations/cy.json +++ b/custom_components/uk_bin_collection/translations/cy.json @@ -6,7 +6,9 @@ "title": "Dewiswch y cyngor", "data": { "name": "Enw lleoliad", - "council": "Cyngor" + "council": "Cyngor", + "icon_color_mapping": "JSON i fapio Math y Bin ar gyfer Lliw ac Eicon gweler: https://github.com/robbrad/UKBinCollectionData" + }, "description": "Gweler [yma](https://github.com/robbrad/UKBinCollectionData#requesting-your-council) os nad yw eich cyngor wedi'i restru" }, @@ -22,7 +24,6 @@ "web_driver": "I redeg ar weinydd Selenium o bell ychwanegwch URL y Gweinydd Selenium", "headless": "Rhedeg Selenium mewn modd headless (argymhellir)", "local_browser": "Peidiwch â rhedeg gweinydd Selenium o bell, defnyddiwch Chrome wedi'i osod yn lleol yn lle", - "icon_color_mapping": "JSON i fapio Math y Bin ar gyfer Lliw ac Eicon gweler: https://github.com/robbrad/UKBinCollectionData", "submit": "Cyflwyno" }, "description": "Cyfeiriwch at [wiki](https://github.com/robbrad/UKBinCollectionData/wiki/Councils) eich cyngor am fanylion ar beth i'w fewnbynnu" diff --git a/custom_components/uk_bin_collection/translations/en.json b/custom_components/uk_bin_collection/translations/en.json index 7ea664cb19..41500a59f0 100644 --- a/custom_components/uk_bin_collection/translations/en.json +++ b/custom_components/uk_bin_collection/translations/en.json @@ -6,7 +6,8 @@ "title": "Select the council", "data": { "name": "Location name", - "council": "Council" + "council": "Council", + "icon_color_mapping":"JSON to map Bin Type for Colour and Icon see: https://github.com/robbrad/UKBinCollectionData" }, "description": "Please see [here](https://github.com/robbrad/UKBinCollectionData#requesting-your-council) if your council isn't listed" }, @@ -21,8 +22,7 @@ "usrn": "USRN (Unique Street Reference Number)", "web_driver": "To run on a remote Selenium Server add the Selenium Server URL", "headless": "Run Selenium in headless mode (recommended)", - "local_browser": "Don't run remote Selenium server, use local install of Chrome instead", - "icon_color_mapping":"JSON to map Bin Type for Colour and Icon see: https://github.com/robbrad/UKBinCollectionData", + "local_browser": "Don't run on remote Selenium server, use local install of Chrome instead", "submit": "Submit" }, "description": "Please refer to your councils [wiki](https://github.com/robbrad/UKBinCollectionData/wiki/Councils) entry for details on what to enter" @@ -34,11 +34,11 @@ "timeout": "The time in seconds for how long the sensor should wait for data", "uprn": "UPRN (Unique Property Reference Number)", "postcode": "Postcode of the address", - "number": "House number of the address", + "number": "House number of the address", "usrn": "USRN (Unique Street Reference Number)", "web_driver": "To run on a remote Selenium Server add the Selenium Server URL", "headless": "Run Selenium in headless mode (recommended)", - "local_browser": "Don't run remote Selenium server, use local install of Chrome instead", + "local_browser": "Don't run on remote Selenium server, use local install of Chrome instead", "icon_color_mapping":"JSON to map Bin Type for Colour and Icon see: https://github.com/robbrad/UKBinCollectionData", "submit": "Submit" }, diff --git a/custom_components/uk_bin_collection/translations/ga.json b/custom_components/uk_bin_collection/translations/ga.json index bf357f4d69..0b5df0ae5e 100644 --- a/custom_components/uk_bin_collection/translations/ga.json +++ b/custom_components/uk_bin_collection/translations/ga.json @@ -6,7 +6,8 @@ "title": "Roghnaigh an comhairle", "data": { "name": "Ainm an tSuímh", - "council": "Comhairle" + "council": "Comhairle", + "icon_color_mapping": "JSON chun Cineál Binn a léarscáiliú le haghaidh Dath agus Deilbhín féach: https://github.com/robbrad/UKBinCollectionData" }, "description": "Féach [anseo](https://github.com/robbrad/UKBinCollectionData#requesting-your-council) más rud é nach bhfuil do chomhairle liostaithe" }, @@ -22,7 +23,6 @@ "web_driver": "Chun a rith ar Fhreastalaí Selenium iargúlta cuir URL Freastalaí Selenium isteach", "headless": "Selenium a rith i mód gan cheann (molta)", "local_browser": "Ná rith freastalaí iargúlta Selenium, bain úsáid as suiteáil áitiúil de Chrome", - "icon_color_mapping": "JSON chun Cineál Binn a léarscáiliú le haghaidh Dath agus Deilbhín féach: https://github.com/robbrad/UKBinCollectionData", "submit": "Cuir isteach" }, "description": "Féach ar iontráil [wiki](https://github.com/robbrad/UKBinCollectionData/wiki/Councils) do chomhairle le haghaidh sonraí ar cad ba cheart a iontráil" diff --git a/custom_components/uk_bin_collection/translations/gd.json b/custom_components/uk_bin_collection/translations/gd.json index 0fbaac20c1..0a82a3540f 100644 --- a/custom_components/uk_bin_collection/translations/gd.json +++ b/custom_components/uk_bin_collection/translations/gd.json @@ -6,7 +6,8 @@ "title": "Tagh a’ chomhairle", "data": { "name": "Ainm an àite", - "council": "Comhairle" + "council": "Comhairle", + "icon_color_mapping": "JSON gus Seòrsa Biona a mhapadh airson Dath agus Ìomhaigh faic: https://github.com/robbrad/UKBinCollectionData" }, "description": "Faic [an seo](https://github.com/robbrad/UKBinCollectionData#requesting-your-council) mura h-eil do chomhairle air a liostadh" }, @@ -22,7 +23,6 @@ "web_driver": "Gus ruith air Freiceadan Selenium iomallach cuir URL Freiceadan Selenium ris", "headless": "Ruith Selenium ann am modh gun cheann (molta)", "local_browser": "Na ruith Freiceadan Selenium iomallach, cleachd stàladh ionadail de Chrome", - "icon_color_mapping": "JSON gus Seòrsa Biona a mhapadh airson Dath agus Ìomhaigh faic: https://github.com/robbrad/UKBinCollectionData", "submit": "Cuir a-steach" }, "description": "Thoir sùil air [wiki](https://github.com/robbrad/UKBinCollectionData/wiki/Councils) na comhairle agad airson fiosrachadh air dè bu chòir a chur a-steach" diff --git a/custom_components/uk_bin_collection/translations/pt.json b/custom_components/uk_bin_collection/translations/pt.json index cbdf610fa1..094abfac4f 100644 --- a/custom_components/uk_bin_collection/translations/pt.json +++ b/custom_components/uk_bin_collection/translations/pt.json @@ -6,7 +6,8 @@ "title": "Selecione o conselho", "data": { "name": "Nome da localidade", - "council": "Conselho" + "council": "Conselho", + "icon_color_mapping": "JSON para mapear o Tipo de Lixeira para Cor e Ícone veja: https://github.com/robbrad/UKBinCollectionData" }, "description": "Por favor veja [aqui](https://github.com/robbrad/UKBinCollectionData#requesting-your-council) se o seu conselho não estiver listado" }, @@ -22,7 +23,6 @@ "web_driver": "Para executar num servidor remoto Selenium, adicione o URL do servidor Selenium", "headless": "Executar o Selenium em modo headless (recomendado)", "local_browser": "Não executar o servidor remoto Selenium, utilizar a instalação local do Chrome", - "icon_color_mapping": "JSON para mapear o Tipo de Lixeira para Cor e Ícone veja: https://github.com/robbrad/UKBinCollectionData", "submit": "Submeter" }, "description": "Por favor, consulte a [wiki](https://github.com/robbrad/UKBinCollectionData/wiki/Councils) do seu conselho para obter detalhes sobre o que inserir" diff --git a/pyproject.toml b/pyproject.toml index 2c64154633..835ae97a54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,13 +52,16 @@ bs4 = "*" python-dateutil = "*" holidays = "*" pandas = "*" -python = ">=3.11" +python = ">=3.12,<3.14" requests = "*" selenium = "*" lxml = "*" urllib3 = "*" webdriver-manager = "^4.0.1" tabulate = "^0.9.0" +pytest-homeassistant-custom-component = "^0.13.177" +pytest-asyncio = "^0.24.0" +pytest-freezer = "^0.4.8" [tool.commitizen] major_version_zero = true diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000..2412f4de21 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +pythonpath = ./uk_bin_collection +asyncio_mode=auto +# Unsetting this will cause testing to fail with a key error for any VALID value +# Leaving it out allows pytest to run, but will generate a warning +asyncio_default_fixture_loop_scope=function +filterwarnings=ignore::DeprecationWarning \ No newline at end of file