diff --git a/.docker/irssi/config b/.docker/irssi/config new file mode 100644 index 0000000..c3d634b --- /dev/null +++ b/.docker/irssi/config @@ -0,0 +1,24 @@ +servers = ( + { + address = "ngircd"; + chatnet = "marvinnet"; + port = "6667"; + autoconnect = "yes"; + } +); + +chatnets = { + marvinnet = { + type = "IRC"; + nick = "User"; + realname = "Marvin Test User"; + }; +}; + +channels = ( + { + name = "#marvintest"; + chatnet = "marvinnet"; + autojoin = "yes"; + } +); diff --git a/.docker/marvin/Dockerfile b/.docker/marvin/Dockerfile index 5c57501..c87447d 100644 --- a/.docker/marvin/Dockerfile +++ b/.docker/marvin/Dockerfile @@ -1,9 +1,11 @@ FROM python:3.11-alpine -COPY . /app RUN mkdir -p /app/logs + +COPY ./dist/*.whl /app + WORKDIR /app -RUN python3 -m pip install -r .requirements.txt +RUN python3 -m pip install *.whl -CMD ["python", "/app/main.py"] +CMD ["irc2phpbb"] diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml deleted file mode 100644 index 53297fb..0000000 --- a/.github/actions/install/action.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Install dependencies -description: Install dependencies for the project -runs: - using: composite - steps: - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - cache: 'pip' - cache-dependency-path: .requirements.txt - - name: Install dependencies - shell: bash - run: make install-tools diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6b5bc8e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: Continuous Integration + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + unit-test: + name: Run unit test + runs-on: ubuntu-24.04 + timeout-minutes: 1 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.9.5" + - name: Run tests + run: uv run pytest + + lint: + name: Run linter + runs-on: ubuntu-24.04 + timeout-minutes: 2 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.9.5" + - name: Run linter on code + run: uv run pylint irc2phpbb diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..c807169 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,40 @@ +name: Documentation + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build: + name: Build API documentation + runs-on: ubuntu-24.04 + timeout-minutes: 1 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.9.5" + - name: Build the API documentation + run: uv run pdoc --output-dir=docs/pdoc irc2phpbb + - name: Deploy documentation to github pages + uses: actions/upload-pages-artifact@v3.0.1 + with: + path: docs + + deploy: + name: Deploy API documentation to github pages + runs-on: ubuntu-24.04 + timeout-minutes: 1 + needs: build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }}/pdoc + permissions: + id-token: write + pages: write + steps: + - name: Deploy to gitub pages + uses: actions/deploy-pages@v4.0.5 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index b910c66..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Continuous integration - -on: - # Trigger the workflow on push or pull request, - push: - pull_request: - # Trigger the workflow via a manual run - workflow_dispatch: - -# Ensures that only one deploy task per branch/environment will run at a time. -concurrency: - group: environment-${{ github.ref }} - cancel-in-progress: true - -jobs: - install: - name: Install dependencies - runs-on: ubuntu-24.04 - timeout-minutes: 1 - steps: - - name: Check out repository - uses: actions/checkout@v4 - - name: Install dependencies - uses: ./.github/actions/install - - unit-test: - name: Run unit test - runs-on: ubuntu-24.04 - timeout-minutes: 1 - needs: [install] - steps: - - name: Check out repository - uses: actions/checkout@v4 - - name: Install dependencies - uses: ./.github/actions/install - - name: Run tests - run: make test - - lint: - name: Lint with Pylint - runs-on: ubuntu-24.04 - timeout-minutes: 1 - needs: [install] - steps: - - name: Check out repository - uses: actions/checkout@v4 - - name: Install dependencies - uses: ./.github/actions/install - - name: Lint with Pylint - run: make pylint - - build: - runs-on: ubuntu-24.04 - needs: [install] - steps: - - name: Check out repository - uses: actions/checkout@v4 - - name: Install dependencies - uses: ./.github/actions/install - - name: Build pdoc - id: build - run: make pdoc - - name: Upload pdoc - id: deploy - uses: actions/upload-pages-artifact@v3.0.1 - with: - path: docs - - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }}/pdoc - runs-on: ubuntu-24.04 - needs: build - steps: - - name: Deploy to GitHub Pages - id: deploy - uses: actions/deploy-pages@v4.0.5 - permissions: - id-token: write - pages: write diff --git a/.gitignore b/.gitignore index 7f974bd..aae5829 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ build/ __pycache__/ data/marvinMorning_date.txt .coverage +dist +htmlcov/ diff --git a/.pylintrc b/.pylintrc index 1fe7c70..4d69f8c 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,17 +1,5 @@ [MESSAGES CONTROL] - -#disable=locally-disabled,locally-enabled -disable=locally-disabled,locally-enabled,invalid-name,unused-argument,broad-except,too-many-branches,global-statement - - +disable=locally-disabled,invalid-name,unused-argument,broad-except,too-many-branches,global-statement [FORMAT] - -# Maximum number of characters on a single line. -#max-line-length=79 max-line-length=100 - - -[REPORTS] - -#reports=no diff --git a/.requirements.txt b/.requirements.txt deleted file mode 100644 index 4a264fb..0000000 --- a/.requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -# For production -feedparser -beautifulsoup4 -chardet -requests -discord - -# For development -pylint >= 1.7.1 -coverage -#pep8 -flake8 -flake8-docstrings -pycodestyle -pdoc diff --git a/Makefile b/Makefile deleted file mode 100644 index 8c8dde9..0000000 --- a/Makefile +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/make -f - -# ---------------------------------------------------------------------------- -# -# Generic stuff -# - -# Detect OS -OS = $(shell uname -s) - -# Defaults -ECHO = echo - -# Make adjustments based on OS -# http://stackoverflow.com/questions/3466166/how-to-check-if-running-in-cygwin-mac-or-linux/27776822#27776822 -ifneq (, $(findstring CYGWIN, $(OS))) - ECHO = /bin/echo -e -endif - -# Colors and helptext -NO_COLOR = \033[0m -ACTION = \033[32;01m -OK_COLOR = \033[32;01m -ERROR_COLOR = \033[31;01m -WARN_COLOR = \033[33;01m - -# Which makefile am I in? -WHERE-AM-I = $(CURDIR)/$(word $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST)) -THIS_MAKEFILE := $(call WHERE-AM-I) - -# Echo some nice helptext based on the target comment -HELPTEXT = $(ECHO) "$(ACTION)--->" `egrep "^\# target: $(1) " $(THIS_MAKEFILE) | sed "s/\# target: $(1)[ ]*-[ ]* / /g"` "$(NO_COLOR)" - -# Add local bin path for test tools -#PATH := "./.bin:./vendor/bin:./node_modules/.bin:$(PATH)" -#SHELL := env PATH=$(PATH) $(SHELL) -PHPUNIT := .bin/phpunit -PHPLOC := .bin/phploc -PHPCS := .bin/phpcs -PHPCBF := .bin/phpcbf -PHPMD := .bin/phpmd -PHPDOC := .bin/phpdoc -BEHAT := .bin/behat - - - -# target: help - Displays help. -.PHONY: help -help: - @$(call HELPTEXT,$@) - @$(ECHO) "Usage:" - @$(ECHO) " make [target] ..." - @$(ECHO) "target:" - @egrep "^# target:" $(THIS_MAKEFILE) | sed 's/# target: / /g' - - - -# ---------------------------------------------------------------------------- -# -# Specifics -# -LOGFILES = aggregate.error aggregate.log aggregate.ignore -PYFILES = *.py -JSONFILES = *.json - - - -# target: clean - Removes generated files and directories. -.PHONY: clean -clean: - @$(call HELPTEXT,$@) - rm -f $(LOGFILES) - rm -rf build - rm -rf __pycache__ - # These should not remove files in .venv - #find . -type f -name '*.pyc' -exec rm -f {} \; - - - -# target: prepare - Prepare for tests and build -.PHONY: prepare -prepare: - @$(call HELPTEXT,$@) - install -d build - - - -# target: test - Run all tests. -.PHONY: test -#test: prepare jsonlint pylint pycodestyle flake8 unittest doctest coverage -test: prepare jsonlint pylint unittest # pycodestyle flake8 doctest coverage - @$(call HELPTEXT,$@) - - - -# target: pylint - Run pylint validation. -.PHONY: pylint -pylint: - @$(call HELPTEXT,$@) - @install -d build/pylint - -pylint --reports=no *.py - -@pylint *.py > build/pylint/output.txt - #pylint --rcfile=.pylintrc $(PYFILES) | tee build/pylint - - - -# target: pycodestyle - Run pycodestyle validation. -.PHONY: pycodestyle -pycodestyle: - @$(call HELPTEXT,$@) - @install -d build/pycodestyle - -pycodestyle --exclude=orig --count --statistics . | tee build/pycodestyle/log.txt - - - -# target: flake8 - Run flake8 validation. -.PHONY: flake8 -flake8: - @$(call HELPTEXT,$@) - @install -d build/flake8 - -flake8 --exclude=orig --count --statistics . | tee build/flake8/log.txt - - - -# target: unittest - Run all unittests. -.PHONY: unittest -unittest: - @$(call HELPTEXT,$@) - python3 -m unittest discover - - - -# target: doctest - Run all doctests. -.PHONY: doctest -doctest: - @$(call HELPTEXT,$@) - python3 -m doctest *.py - - - -# target: coverage - Run code coverage of all unittests. -.PHONY: coverage -coverage: - @$(call HELPTEXT,$@) - @install -d build/coverage - @rm -f build/coverage/* - coverage run --source=. -m unittest discover -b - coverage html --directory=build/coverage --omit=test_* - coverage report -m --omit=test_* - - - -# target: pdoc - Create documentation of the code. -.PHONY: pdoc -pdoc: - @$(call MESSAGE,$@) - pdoc --output-dir docs/pdoc *.py - - - -# target: install-tools - Install needed devtools. -.PHONY: install-tools -install-tools: - @$(call HELPTEXT,$@) - python3 -m pip install --requirement .requirements.txt - - - -# target: upgrade-tools - Upgrade needed devtools. -.PHONY: upgrade-tools -upgrade-tools: - @$(call HELPTEXT,$@) - python3 -m pip install --upgrade --requirement .requirements.txt - - -# target: check - Check versions of installed devtools. -.PHONY: check -check: - @$(call HELPTEXT,$@) - @$(ECHO) "$(INFO_COLOR)python:$(NO_COLOR)" && which python3 && python3 --version - @$(ECHO) "\n$(INFO_COLOR)pip:$(NO_COLOR)" && python3 -m pip --version - @$(ECHO) "\n$(INFO_COLOR)pylint:$(NO_COLOR)" && which pylint && pylint --version - @$(ECHO) "\n$(INFO_COLOR)coverage:$(NO_COLOR)" && which coverage && coverage --version - @$(ECHO) "\n$(INFO_COLOR)flake8:$(NO_COLOR)" && which flake8 && flake8 --version - @$(ECHO) "\n$(INFO_COLOR)pycodestyle:$(NO_COLOR)" && which pycodestyle && pycodestyle --version - - -# -# -# target: jsonlint - Validate all JSON files. -.PHONY: jsonlint -jsonlint: $(JSONFILES) - @$(call HELPTEXT,$@) - -$(JSONFILES): - jsonlint --quiet $@ 2>&1 | tee build/jsonlint-$@ diff --git a/README.md b/README.md index 1874ecc..8d3bbee 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Marvin, an IRC bot +Marvin, an IRC/discord bot ================== [![Build Status GitHub Actions](https://github.com/mosbth/irc2phpbb/actions/workflows/main.yml/badge.svg)](https://github.com/mosbth/irc2phpbb/actions) @@ -7,8 +7,7 @@ Marvin, an IRC bot [![Code Coverage](https://scrutinizer-ci.com/g/mosbth/irc2phpbb/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/mosbth/irc2phpbb/?branch=master) ======= -Get a quick start by checking out the main script `main.py` and read on how to contribute. - +Marvin was originally an IRC bot (now also supporting discord) that responds to basic questions and provides guidance in the life of anyone involved in any [dbwebb](https://www.dbwebb.se) courses. Contribute @@ -16,84 +15,69 @@ Contribute Before you actually start contributing, create an issue and discuss what you want to do. This is just to avoid that your PR will be denied for some random reason. -Create your own virtual environment, install the local development environment and run the script. - -```bash -$ python3 -m venv .venv -$ alias activate='. .venv/bin/activate' -$ activate -$ make install-tools -$ make test -$ python3 main.py -$ deactivate -``` - -Check `main.py` for more details (should be moved to pydoc or other proper documentation like here in this README...). +This project uses [`uv`](https://github.com/astral-sh/uv) to manage dependencies and tools. Refer their [documentation](https://docs.astral.sh/uv/getting-started/) for instructions how to install it and getting started. -Verify unitttest and code coverage +Running tests and code coverage -------------------------- Run the unittests. ```bash -make unittest +uv run pytest ``` -Run code coverage and create reports. +Run code coverage and report results in terminal. ```bash -make coverage +uv run pytest --cov=irc2phpbb ``` -A html report of the code coverage is generated into `build/coverage/index.html`. - +Run pylint on both production code and the tests. +```bash +uv run pylint irc2phpbb +uv run pylint tests +``` +Run code coverage and create an html report. An html report of the code coverage is generated in `htmlcov/index.html`. [Other report formats](https://pytest-cov.readthedocs.io/en/latest/reporting.html) are also supported. If you generate other formats, take care not to commit them to the repository. +```bash +uv run pytest --cov=irc2phpbb --cov-report=html +``` Execute marvin in docker -------------------------- +The easiest way to run marvin in a *real* setting is to run it in IRC mode, as that doesn't require any registration with discord services. -Start the irc-server [ngircd](https://hub.docker.com/r/linuxserver/ngircd) using docker (in its own terminal window). +Build the python package and the docker image, then start marvin as a container in the background. ```bash -docker compose up ngircd +uv build +docker compose build +docker compose up -d marvin ``` -Now start the irc-client [irssi](https://hub.docker.com/_/irssi) through docker (or from your desktop) in another terminal. +Now you can connect to `localhost` with any IRC client of your choice, or you can follow the instructions below to run [irssi](https://irssi.org/) in a [container](https://hub.docker.com/_/irssi). ```bash -docker compose run irssi +docker compose run --rm irssi ``` +You should be automatically connected to the server and join the `#marvin` channel. -Use the following commands in your irc-client to connect and join the channel where marvin will be. +When you are done, you can shut down all the containers. +```bash +docker compose down ``` -/connect ngircd -/join #marvin -``` - -If you are using a client outside of docker, then connect to localhost instead of ngircd. - -Then build and start marvin through docker (in a third terminal). - -``` -docker compose up marvin -``` - -Marvin will join your channel and then you can start playing. - - API documentation -------------------------- -The code and API documentation is generated using pdoc and make. +The code and API documentation is generated using [pdoc](https://pdoc.dev/). ```bash -make pdoc +uv run pdoc --output-dir=docs/pdoc irc2phpbb ``` - The docs are saved at `docs/pdoc` and can be [viewed online](https://mosbth.github.io/irc2phpbb/pdoc/). diff --git a/docker-compose.yml b/docker-compose.yml index 93f3f99..d3c907f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: - TERM=screen-256color - COLORTERM=truecolor volumes: - - $HOME/.irssi:/home/user/.irssi:ro + - .docker/irssi:/home/user/.irssi:ro - /etc/localtime:/etc/localtime:ro depends_on: - ngircd diff --git a/data/.gitignore b/irc2phpbb/__init__.py similarity index 100% rename from data/.gitignore rename to irc2phpbb/__init__.py diff --git a/main.py b/irc2phpbb/__main__.py similarity index 92% rename from main.py rename to irc2phpbb/__main__.py index 32021f1..c4f32a3 100755 --- a/main.py +++ b/irc2phpbb/__main__.py @@ -46,18 +46,21 @@ import os import sys -from discord_bot import DiscordBot -from irc_bot import IrcBot +from importlib import resources as impresources + +from irc2phpbb.discord_bot import DiscordBot +from irc2phpbb.irc_bot import IrcBot + +from irc2phpbb import marvin_actions +from irc2phpbb import marvin_general_actions + +from . import config as pkgconfig -import marvin_actions -import marvin_general_actions # # General stuff about this program # PROGRAM = "marvin" -AUTHOR = "Mikael Roos" -EMAIL = "mikael.t.h.roos@gmail.com" VERSION = "0.3.0" MSG_VERSION = f"{PROGRAM} version {VERSION}." @@ -138,7 +141,8 @@ def createBot(protocol): def setupLogging(): """Set up the logging config""" - with open("logging.json", encoding="UTF-8") as f: + logConfig = impresources.files(pkgconfig) / "logging.json" + with open(logConfig, encoding="UTF-8") as f: config = json.load(f) logging.config.dictConfig(config) diff --git a/bot.py b/irc2phpbb/bot.py similarity index 100% rename from bot.py rename to irc2phpbb/bot.py diff --git a/logging.json b/irc2phpbb/config/logging.json similarity index 100% rename from logging.json rename to irc2phpbb/config/logging.json diff --git a/marvin_config_default.json b/irc2phpbb/config/marvin_config_default.json similarity index 100% rename from marvin_config_default.json rename to irc2phpbb/config/marvin_config_default.json diff --git a/marvin_strings.json b/irc2phpbb/data/marvin_strings.json similarity index 91% rename from marvin_strings.json rename to irc2phpbb/data/marvin_strings.json index aadd6ca..556dc5a 100644 --- a/marvin_strings.json +++ b/irc2phpbb/data/marvin_strings.json @@ -3,7 +3,7 @@ "whois": "Jag är en tjänstvillig själ som gillar webbprogrammering. Jag bor på GitHub https://github.com/mosbth/irc2phpbb och du kan diskutera mig i forumet https://dbwebb.se/t/20", - "menu": "[ vem är | le | lunch [var] | citat | budord 1 - 5 | source | väder | solen | dagens video | nöje/paus/strip/comic [slump] | grill | nameday/namnsdag | google/googla | explain/förklara | uptime | stream | princip | skämt/joke | hjälp ]", + "menu": "[ vem är | le | lunch [var] | citat | budord 1 - 7 | source | väder | solen | dagens video | nöje/paus/strip/comic [slump] | grill | nameday/namnsdag | google/googla | explain/förklara | uptime | stream | princip | skämt/joke | elpris | hjälp ]", "google": [ "Googla {}", @@ -59,12 +59,13 @@ ], "budord": { - "#1": "Ställ din fråga, länka till exempel och källkod. Häng kvar och vänta på svar.", - "#2": "Var inte rädd för att fråga och fråga tills du får svar: https://dbwebb.se/f/6249", - "#3": "Öva dig ställa smarta frågor: https://dbwebb.se/f/7802", - "#4": "When in doubt - gör ett testprogram. https://dbwebb.se/f/13570", - "#5": "Hey Luke - use the source! https://catb.org/jargon/html/U/UTSL.html", - "#6": "Om du googlar på felkoden från pylint så hittar du bra förklaringar på nätet, med exempel!" + "1": "Ställ din fråga, länka till exempel och källkod. Häng kvar och vänta på svar.", + "2": "Var inte rädd för att fråga och fråga tills du får svar: https://dbwebb.se/f/6249", + "3": "Öva dig ställa smarta frågor: https://dbwebb.se/f/7802", + "4": "When in doubt - gör ett testprogram. https://dbwebb.se/f/13570", + "5": "Hey Luke - use the source! https://catb.org/jargon/html/U/UTSL.html", + "6": "Om du googlar på felkoden från pylint så hittar du bra förklaringar på nätet, med exempel!", + "7": "Skyll alltid på Dansken" }, "video-of-today": { @@ -259,5 +260,14 @@ "commit": { "url": "https://whatthecommit.com/index.txt", "error": "Du får komma på ett själv. Jag är trasig för tillfället!" + }, + "powerprice": { + "url": "https://dataportal-api.nordpoolgroup.com/api/DayAheadPrices?date={}&market=DayAhead&deliveryArea=SE4¤cy=SEK", + "msg": "Just nu kostar en kWh {:.4f} SEK i {}.", + "exclamation": [ + "Huvva!", + "Det kostar att ligga på topp!", + "Nu är det så dyrt att smålänningarna skruvar ner värmen..." + ] } } diff --git a/discord_bot.py b/irc2phpbb/discord_bot.py similarity index 85% rename from discord_bot.py rename to irc2phpbb/discord_bot.py index 8402955..e8edd7c 100644 --- a/discord_bot.py +++ b/irc2phpbb/discord_bot.py @@ -9,7 +9,7 @@ import discord -from bot import Bot +from irc2phpbb.bot import Bot class DiscordBot(discord.Client, Bot): @@ -31,10 +31,15 @@ async def checkMarvinActions(self, message): """Check if Marvin should perform any actions""" words = self.tokenize(message.content) if self.user.mentioned_in(message) or self.user.name.lower() in words: + first = True for action in self.ACTIONS: response = action(words) if response: - await message.reply(response) + if first: + await message.reply(response) + first = False + else: + await message.channel.send(response) else: for action in self.GENERAL_ACTIONS: response = action(words) diff --git a/irc_bot.py b/irc2phpbb/irc_bot.py similarity index 99% rename from irc_bot.py rename to irc2phpbb/irc_bot.py index 645cfcb..391d567 100755 --- a/irc_bot.py +++ b/irc2phpbb/irc_bot.py @@ -15,7 +15,7 @@ import chardet -from bot import Bot +from irc2phpbb.bot import Bot LOG = logging.getLogger("bot") diff --git a/marvin_actions.py b/irc2phpbb/marvin_actions.py similarity index 90% rename from marvin_actions.py rename to irc2phpbb/marvin_actions.py index 460f74a..5326ab6 100644 --- a/marvin_actions.py +++ b/irc2phpbb/marvin_actions.py @@ -10,8 +10,14 @@ import json import logging import random +import re + +from importlib import resources as impresources + import requests +from . import data as pkgdata + LOG = logging.getLogger("action") @@ -40,12 +46,14 @@ def getAllActions(): marvinStream, marvinPrinciple, marvinJoke, - marvinCommit + marvinCommit, + marvinPowerPrice ] # Load all strings from file -with open("marvin_strings.json", encoding="utf-8") as f: +strings = impresources.files(pkgdata) / "marvin_strings.json" +with open(strings, encoding="utf-8") as f: STRINGS = json.load(f) @@ -144,17 +152,9 @@ def marvinBudord(row): """ msg = None if any(r in row for r in ["budord", "stentavla"]): - if any(r in row for r in ["#1", "1"]): - msg = getString("budord", "#1") - elif any(r in row for r in ["#2", "2"]): - msg = getString("budord", "#2") - elif any(r in row for r in ["#3", "3"]): - msg = getString("budord", "#3") - elif any(r in row for r in ["#4", "4"]): - msg = getString("budord", "#4") - elif any(r in row for r in ["#5", "5"]): - msg = getString("budord", "#5") - + number = re.search(r"\d+", " ".join(row)).group(0) + if number: + msg = getString("budord", number) return msg @@ -519,3 +519,35 @@ def marvinCommit(row): if any(r in row for r in ["commit", "-m"]): msg = getCommit() return msg + +def marvinPowerPrice(row): + """ + Display the current power price + """ + msg = None + if any(r in row for r in ["elpris"]): + try: + price = getPowerPrice(datetime.datetime.utcnow(), "SE4") + except Exception as e: + LOG.error("Failed to get power price: %s", e) + return None + msg = getString("powerprice", "msg").format(price, "SE4") + if price > 1.5: + msg += " " + getString("powerprice", "exclamation") + return msg + + +def getPowerPrice(time, area): + """ + Return todays power price (in SEK per kWh) at the given time of day in an area + """ + today = datetime.datetime.today().strftime('%Y-%m-%d') + r = requests.get(getString("powerprice").get("url").format(today), timeout=5) + data = r.json().get("multiAreaEntries") + for entry in data: + start = datetime.datetime.fromisoformat(entry.get("deliveryStart")) + if start.hour == time.hour: + price = entry.get("entryPerArea").get(area) + break + price /= 1000 # MWh -> kWh + return price diff --git a/marvin_general_actions.py b/irc2phpbb/marvin_general_actions.py similarity index 100% rename from marvin_general_actions.py rename to irc2phpbb/marvin_general_actions.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b5241d7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "irc2phpbb" +version = "0.4.0" +authors = [{ name = "Mikael Roos", email = "mos@bth.se" }] +description = "A chatbot that answers random questions (un)related to dbwebb" +requires-python = ">=3.10" +readme = "README.md" +dependencies = [ + "discord (>=2.3.2,<3.0.0)", + "requests (>=2.32.4,<3.0.0)", + "chardet (>=5.2.0,<6.0.0)", +] + +[project.urls] +homepage = "https://www.dbwebb.se" +repository = "https://github.com/mosbth/irc2phpbb" +issues = "https://github.com/mosbth/irc2phpbb/issues" + +[project.scripts] +irc2phpbb = "irc2phpbb.__main__:main" + +[dependency-groups] +test = ["pytest>=8.4.1,<9"] +dev = [ + "pylint>=3.3.8,<4", + "flake8>=7.3.0,<8", + "flake8-docstrings>=1.7.0,<2", + "pycodestyle>=2.14.0,<3", + "pdoc>=15.0.4,<16", + "pytest-cov>=7.0.0,<8", +] + +[tool.uv] +default-groups = [ + "test", + "dev", +] diff --git a/test_marvin_actions.py b/test_marvin_actions.py deleted file mode 100644 index 6a62b02..0000000 --- a/test_marvin_actions.py +++ /dev/null @@ -1,381 +0,0 @@ -#! /usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -Tests for all Marvin actions -""" - -import json -import os - -from datetime import date, timedelta -from unittest import mock, TestCase - -import requests - -from bot import Bot -import marvin_actions -import marvin_general_actions - -class ActionTest(TestCase): - """Test Marvin actions""" - strings = {} - - @classmethod - def setUpClass(cls): - with open("marvin_strings.json", encoding="utf-8") as f: - cls.strings = json.load(f) - - - def executeAction(self, action, message): - """Execute an action for a message and return the response""" - return action(Bot.tokenize(message)) - - - def assertActionOutput(self, action, message, expectedOutput): - """Call an action on message and assert expected output""" - actualOutput = self.executeAction(action, message) - - self.assertEqual(actualOutput, expectedOutput) - - - def assertActionSilent(self, action, message): - """Call an action with provided message and assert no output""" - self.assertActionOutput(action, message, None) - - - def assertStringsOutput(self, action, message, expectedoutputKey, subkey=None): - """Call an action with provided message and assert the output is equal to DB""" - expectedOutput = self.strings.get(expectedoutputKey) - if subkey is not None: - if isinstance(expectedOutput, list): - expectedOutput = expectedOutput[subkey] - else: - expectedOutput = expectedOutput.get(subkey) - self.assertActionOutput(action, message, expectedOutput) - - - def assertBBQResponse(self, todaysDate, bbqDate, expectedMessageKey): - """Assert that the proper bbq message is returned, given a date""" - url = self.strings.get("barbecue").get("url") - message = self.strings.get("barbecue").get(expectedMessageKey) - if isinstance(message, list): - message = message[1] - if expectedMessageKey in ["base", "week", "eternity"]: - message = message % bbqDate - - with mock.patch("marvin_actions.datetime") as d, mock.patch("marvin_actions.random") as r: - d.date.today.return_value = todaysDate - r.randint.return_value = 1 - expected = f"{url}. {message}" - self.assertActionOutput(marvin_actions.marvinTimeToBBQ, "dags att grilla", expected) - - - def createResponseFrom(self, directory, filename): - """Create a response object with contect as contained in the specified file""" - with open(os.path.join(directory, f"{filename}.json"), "r", encoding="UTF-8") as f: - response = requests.models.Response() - response._content = str.encode(json.dumps(json.load(f))) - return response - - - def assertNameDayOutput(self, exampleFile, expectedOutput): - """Assert that the proper nameday message is returned, given an inputfile""" - response = self.createResponseFrom("namedayFiles", exampleFile) - with mock.patch("marvin_actions.requests") as r: - r.get.return_value = response - self.assertActionOutput(marvin_actions.marvinNameday, "nameday", expectedOutput) - - - def assertJokeOutput(self, exampleFile, expectedOutput): - """Assert that a joke is returned, given an input file""" - response = self.createResponseFrom("jokeFiles", exampleFile) - with mock.patch("marvin_actions.requests") as r: - r.get.return_value = response - self.assertActionOutput(marvin_actions.marvinJoke, "joke", expectedOutput) - - - def assertSunOutput(self, expectedOutput): - """Test that marvin knows when the sun comes up, given an input file""" - response = self.createResponseFrom("sunFiles", "sun") - with mock.patch("marvin_actions.requests") as r: - r.get.return_value = response - self.assertActionOutput(marvin_actions.marvinSun, "sol", expectedOutput) - - - def testSmile(self): - """Test that marvin can smile""" - with mock.patch("marvin_actions.random") as r: - r.randint.return_value = 1 - self.assertStringsOutput(marvin_actions.marvinSmile, "le lite?", "smile", 1) - self.assertActionSilent(marvin_actions.marvinSmile, "sur idag?") - - def testWhois(self): - """Test that marvin responds to whois""" - self.assertStringsOutput(marvin_actions.marvinWhoIs, "vem är marvin?", "whois") - self.assertActionSilent(marvin_actions.marvinWhoIs, "vemär") - - def testGoogle(self): - """Test that marvin can help google stuff""" - with mock.patch("marvin_actions.random") as r: - r.randint.return_value = 1 - self.assertActionOutput( - marvin_actions.marvinGoogle, - "kan du googla mos", - "LMGTFY https://www.google.se/search?q=mos") - self.assertActionOutput( - marvin_actions.marvinGoogle, - "kan du googla google mos", - "LMGTFY https://www.google.se/search?q=google+mos") - self.assertActionSilent(marvin_actions.marvinGoogle, "du kan googla") - self.assertActionSilent(marvin_actions.marvinGoogle, "gogool") - - def testExplainShell(self): - """Test that marvin can explain shell commands""" - url = "https://explainshell.com/explain?cmd=pwd" - self.assertActionOutput(marvin_actions.marvinExplainShell, "explain pwd", url) - self.assertActionOutput(marvin_actions.marvinExplainShell, "can you explain pwd", url) - self.assertActionOutput( - marvin_actions.marvinExplainShell, - "förklara pwd|grep -o $user", - f"{url}%7Cgrep+-o+%24user") - - self.assertActionSilent(marvin_actions.marvinExplainShell, "explains") - - def testSource(self): - """Test that marvin responds to questions about source code""" - self.assertStringsOutput(marvin_actions.marvinSource, "source", "source") - self.assertStringsOutput(marvin_actions.marvinSource, "källkod", "source") - self.assertActionSilent(marvin_actions.marvinSource, "opensource") - - def testBudord(self): - """Test that marvin knows all the commandments""" - for n, _ in enumerate(self.strings.get("budord")): - self.assertStringsOutput(marvin_actions.marvinBudord, f"budord #{n}", "budord", f"#{n}") - - self.assertStringsOutput(marvin_actions.marvinBudord,"visa stentavla 1", "budord", "#1") - self.assertActionSilent(marvin_actions.marvinBudord, "var är stentavlan?") - - def testQuote(self): - """Test that marvin can quote The Hitchhikers Guide to the Galaxy""" - with mock.patch("marvin_actions.random") as r: - r.randint.return_value = 1 - self.assertStringsOutput(marvin_actions.marvinQuote, "ge os ett citat", "hitchhiker", 1) - self.assertStringsOutput(marvin_actions.marvinQuote, "filosofi", "hitchhiker", 1) - self.assertStringsOutput(marvin_actions.marvinQuote, "filosofera", "hitchhiker", 1) - self.assertActionSilent(marvin_actions.marvinQuote, "noquote") - - for i,_ in enumerate(self.strings.get("hitchhiker")): - r.randint.return_value = i - self.assertStringsOutput(marvin_actions.marvinQuote, "quote", "hitchhiker", i) - - def testVideoOfToday(self): - """Test that marvin can link to a different video each day of the week""" - with mock.patch("marvin_actions.datetime") as dt: - for d in range(1, 8): - day = date(2024, 11, 25) + timedelta(days=d) - dt.date.today.return_value = day - weekday = day.strftime("%A") - weekdayPhrase = self.strings.get("video-of-today").get(weekday).get("message") - videoPhrase = self.strings.get("video-of-today").get(weekday).get("url") - response = f"{weekdayPhrase} En passande video är {videoPhrase}" - self.assertActionOutput(marvin_actions.marvinVideoOfToday, "dagens video", response) - self.assertActionSilent(marvin_actions.marvinVideoOfToday, "videoidag") - - def testHelp(self): - """Test that marvin can provide a help menu""" - self.assertStringsOutput(marvin_actions.marvinHelp, "help", "menu") - self.assertActionSilent(marvin_actions.marvinHelp, "halp") - - def testSayHi(self): - """Test that marvin responds to greetings""" - with mock.patch("marvin_actions.random") as r: - for skey, s in enumerate(self.strings.get("smile")): - for hkey, h in enumerate(self.strings.get("hello")): - for fkey, f in enumerate(self.strings.get("friendly")): - r.randint.side_effect = [skey, hkey, fkey] - self.assertActionOutput(marvin_actions.marvinSayHi, "hej", f"{s} {h} {f}") - self.assertActionSilent(marvin_actions.marvinSayHi, "korsning") - - def testLunchLocations(self): - """Test that marvin can provide lunch suggestions for certain places""" - locations = ["karlskrona", "goteborg", "angelholm", "hassleholm", "malmo"] - with mock.patch("marvin_actions.random") as r: - for location in locations: - for i, place in enumerate(self.strings.get("lunch").get("location").get(location)): - r.randint.side_effect = [0, i] - self.assertActionOutput( - marvin_actions.marvinLunch, f"mat {location}", f"Ska vi ta {place}?") - r.randint.side_effect = [1, 2] - self.assertActionOutput( - marvin_actions.marvinLunch, "dags att luncha", "Jag är lite sugen på Indiska?") - self.assertActionSilent(marvin_actions.marvinLunch, "matdags") - - def testStrip(self): - """Test that marvin can recommend comics""" - messageFormat = self.strings.get("commitstrip").get("message") - expected = messageFormat.format(url=self.strings.get("commitstrip").get("url")) - self.assertActionOutput(marvin_actions.marvinStrip, "lite strip kanske?", expected) - self.assertActionSilent(marvin_actions.marvinStrip, "nostrip") - - def testRandomStrip(self): - """Test that marvin can recommend random comics""" - messageFormat = self.strings.get("commitstrip").get("message") - expected = messageFormat.format(url=self.strings.get("commitstrip").get("urlPage") + "123") - with mock.patch("marvin_actions.random") as r: - r.randint.return_value = 123 - self.assertActionOutput(marvin_actions.marvinStrip, "random strip kanske?", expected) - - def testTimeToBBQ(self): - """Test that marvin knows when the next BBQ is""" - self.assertBBQResponse(date(2024, 5, 17), date(2024, 5, 17), "today") - self.assertBBQResponse(date(2024, 5, 16), date(2024, 5, 17), "tomorrow") - self.assertBBQResponse(date(2024, 5, 10), date(2024, 5, 17), "week") - self.assertBBQResponse(date(2024, 5, 1), date(2024, 5, 17), "base") - self.assertBBQResponse(date(2023, 10, 17), date(2024, 5, 17), "eternity") - - self.assertBBQResponse(date(2024, 9, 20), date(2024, 9, 20), "today") - self.assertBBQResponse(date(2024, 9, 19), date(2024, 9, 20), "tomorrow") - self.assertBBQResponse(date(2024, 9, 13), date(2024, 9, 20), "week") - self.assertBBQResponse(date(2024, 9, 4), date(2024, 9, 20), "base") - - def testNameDayReaction(self): - """Test that marvin only responds to nameday when asked""" - self.assertActionSilent(marvin_actions.marvinNameday, "anything") - - def testNameDayRequest(self): - """Test that marvin sends a proper request for nameday info""" - with mock.patch("marvin_actions.requests") as r, mock.patch("marvin_actions.datetime") as d: - d.datetime.now.return_value = date(2024, 1, 2) - self.executeAction(marvin_actions.marvinNameday, "namnsdag") - self.assertEqual(r.get.call_args.args[0], "https://api.dryg.net/dagar/v2.1/2024/1/2") - - def testNameDayResponse(self): - """Test that marvin properly parses nameday responses""" - self.assertNameDayOutput("single", "Idag har Svea namnsdag") - self.assertNameDayOutput("double", "Idag har Alfred och Alfrida namnsdag") - self.assertNameDayOutput("triple", "Idag har Kasper, Melker och Baltsar namnsdag") - self.assertNameDayOutput("nobody", "Ingen har namnsdag idag") - - def testNameDayError(self): - """Tests that marvin returns the proper error message when nameday API is down""" - with mock.patch("marvin_actions.requests.get", side_effect=Exception("API Down!")): - self.assertStringsOutput( - marvin_actions.marvinNameday, - "har någon namnsdag idag?", - "nameday", - "error") - - def testJokeRequest(self): - """Test that marvin sends a proper request for a joke""" - with mock.patch("marvin_actions.requests") as r: - self.executeAction(marvin_actions.marvinJoke, "joke") - self.assertEqual( - r.get.call_args.args[0], - "https://api.chucknorris.io/jokes/random?category=dev") - - def testJoke(self): - """Test that marvin sends a joke when requested""" - self.assertJokeOutput("joke", "There is no Esc key on Chuck Norris' keyboard, because no one escapes Chuck Norris.") - - def testJokeError(self): - """Tests that marvin returns the proper error message when joke API is down""" - with mock.patch("marvin_actions.requests.get", side_effect=Exception("API Down!")): - self.assertStringsOutput(marvin_actions.marvinJoke, "kör ett skämt", "joke", "error") - - def testSun(self): - """Test that marvin sends the sunrise and sunset times """ - self.assertSunOutput( - "Idag går solen upp 7:12 och ner 18:21. Iallafall i trakterna kring BTH.") - - def testSunError(self): - """Tests that marvin returns the proper error message when joke API is down""" - with mock.patch("marvin_actions.requests.get", side_effect=Exception("API Down!")): - self.assertStringsOutput(marvin_actions.marvinSun, "när går solen ner?", "sun", "error") - - def testUptime(self): - """Test that marvin can provide the link to the uptime tournament""" - self.assertStringsOutput(marvin_actions.marvinUptime, "visa lite uptime", "uptime", "info") - self.assertActionSilent(marvin_actions.marvinUptime, "uptimetävling") - - def testStream(self): - """Test that marvin can provide the link to the stream""" - self.assertStringsOutput(marvin_actions.marvinStream, "ska mos streama?", "stream", "info") - self.assertActionSilent(marvin_actions.marvinStream, "är mos en streamer?") - - def testPrinciple(self): - """Test that marvin can recite some software principles""" - principles = self.strings.get("principle") - for key, value in principles.items(): - self.assertActionOutput(marvin_actions.marvinPrinciple, f"princip {key}", value) - with mock.patch("marvin_actions.random") as r: - r.choice.return_value = "dry" - self.assertStringsOutput(marvin_actions.marvinPrinciple, "princip", "principle", "dry") - self.assertActionSilent(marvin_actions.marvinPrinciple, "principlös") - - def testCommitRequest(self): - """Test that marvin sends proper requests when generating commit messages""" - with mock.patch("marvin_actions.requests") as r: - self.executeAction(marvin_actions.marvinCommit, "vad skriver man efter commit -m?") - self.assertEqual(r.get.call_args.args[0], "https://whatthecommit.com/index.txt") - - def testCommitResponse(self): - """Test that marvin properly handles responses when generating commit messages""" - message = "Secret sauce #9" - response = requests.models.Response() - response._content = str.encode(message) - with mock.patch("marvin_actions.requests") as r: - r.get.return_value = response - expected = f"Använd detta meddelandet: '{message}'" - self.assertActionOutput(marvin_actions.marvinCommit, "commit", expected) - - def testWeatherRequest(self): - """Test that marvin sends the expected requests for weather info""" - with mock.patch("marvin_actions.requests") as r: - self.executeAction(marvin_actions.marvinWeather, "väder") - for url in ["https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/13/station/65090/period/latest-hour/data.json", - "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/13/codes.json", - "https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/15.5890/lat/56.1500/data.json"]: - self.assertTrue(mock.call(url, timeout=5) in r.get.call_args_list) - - def testWeatherResponse(self): - """Test that marvin properly parses weather responses""" - responses = [] - for responseFile in ["station.json", "codes.json", "weather.json"]: - with open(os.path.join("weatherFiles", responseFile), "r", encoding="UTF-8") as f: - response = requests.models.Response() - response._content = str.encode(json.dumps(json.load(f))) - responses.append(response) - - with mock.patch("marvin_actions.requests") as r: - r.get.side_effect = responses - expected = "Karlskrona just nu: 11.7 °C. Inget signifikant väder observerat." - self.assertActionOutput(marvin_actions.marvinWeather, "väder", expected) - - def testCommitReaction(self): - """Test that marvin only generates commit messages when asked""" - self.assertActionSilent(marvin_actions.marvinCommit, "nocommit") - - - def testCommitError(self): - """Tests that marvin sends the proper message when get commit fails""" - with mock.patch("marvin_actions.requests.get", side_effect=Exception('API Down!')): - self.assertStringsOutput( - marvin_actions.marvinCommit, - "vad skriver man efter commit -m?", - "commit", - "error") - - def testMorning(self): - """Test that marvin wishes good morning, at most once per day""" - marvin_general_actions.lastDateGreeted = None - with mock.patch("marvin_general_actions.datetime") as d: - d.date.today.return_value = date(2024, 5, 17) - with mock.patch("marvin_general_actions.random") as r: - r.choice.return_value = "Morgon" - self.assertActionOutput(marvin_general_actions.marvinMorning, "morrn", "Morgon") - # Should only greet once per day - self.assertActionSilent(marvin_general_actions.marvinMorning, "morgon") - # Should greet again tomorrow - d.date.today.return_value = date(2024, 5, 18) - self.assertActionOutput(marvin_general_actions.marvinMorning, "godmorgon", "Morgon") diff --git a/testConfigs/empty.json b/tests/resources/config/empty.json similarity index 100% rename from testConfigs/empty.json rename to tests/resources/config/empty.json diff --git a/testConfigs/server.json b/tests/resources/config/server.json similarity index 100% rename from testConfigs/server.json rename to tests/resources/config/server.json diff --git a/testConfigs/single.json b/tests/resources/config/single.json similarity index 100% rename from testConfigs/single.json rename to tests/resources/config/single.json diff --git a/jokeFiles/joke.json b/tests/resources/joke/joke.json similarity index 100% rename from jokeFiles/joke.json rename to tests/resources/joke/joke.json diff --git a/namedayFiles/double.json b/tests/resources/nameday/double.json similarity index 100% rename from namedayFiles/double.json rename to tests/resources/nameday/double.json diff --git a/namedayFiles/nobody.json b/tests/resources/nameday/nobody.json similarity index 100% rename from namedayFiles/nobody.json rename to tests/resources/nameday/nobody.json diff --git a/namedayFiles/single.json b/tests/resources/nameday/single.json similarity index 100% rename from namedayFiles/single.json rename to tests/resources/nameday/single.json diff --git a/namedayFiles/triple.json b/tests/resources/nameday/triple.json similarity index 100% rename from namedayFiles/triple.json rename to tests/resources/nameday/triple.json diff --git a/tests/resources/powerPriceFiles/singleAreaResponse.json b/tests/resources/powerPriceFiles/singleAreaResponse.json new file mode 100644 index 0000000..1a75731 --- /dev/null +++ b/tests/resources/powerPriceFiles/singleAreaResponse.json @@ -0,0 +1,233 @@ +{ + "deliveryDateCET": "2025-01-09", + "version": 3, + "updatedAt": "2025-01-08T12:15:06.3553052Z", + "deliveryAreas": [ + "SE4" + ], + "market": "DayAhead", + "multiAreaEntries": [ + { + "deliveryStart": "2025-01-08T23:00:00Z", + "deliveryEnd": "2025-01-09T00:00:00Z", + "entryPerArea": { + "SE4": 390.1 + } + }, + { + "deliveryStart": "2025-01-09T00:00:00Z", + "deliveryEnd": "2025-01-09T01:00:00Z", + "entryPerArea": { + "SE4": 366.72 + } + }, + { + "deliveryStart": "2025-01-09T01:00:00Z", + "deliveryEnd": "2025-01-09T02:00:00Z", + "entryPerArea": { + "SE4": 360.73 + } + }, + { + "deliveryStart": "2025-01-09T02:00:00Z", + "deliveryEnd": "2025-01-09T03:00:00Z", + "entryPerArea": { + "SE4": 367.3 + } + }, + { + "deliveryStart": "2025-01-09T03:00:00Z", + "deliveryEnd": "2025-01-09T04:00:00Z", + "entryPerArea": { + "SE4": 388.03 + } + }, + { + "deliveryStart": "2025-01-09T04:00:00Z", + "deliveryEnd": "2025-01-09T05:00:00Z", + "entryPerArea": { + "SE4": 499.4 + } + }, + { + "deliveryStart": "2025-01-09T05:00:00Z", + "deliveryEnd": "2025-01-09T06:00:00Z", + "entryPerArea": { + "SE4": 778.82 + } + }, + { + "deliveryStart": "2025-01-09T06:00:00Z", + "deliveryEnd": "2025-01-09T07:00:00Z", + "entryPerArea": { + "SE4": 1682.84 + } + }, + { + "deliveryStart": "2025-01-09T07:00:00Z", + "deliveryEnd": "2025-01-09T08:00:00Z", + "entryPerArea": { + "SE4": 1865.74 + } + }, + { + "deliveryStart": "2025-01-09T08:00:00Z", + "deliveryEnd": "2025-01-09T09:00:00Z", + "entryPerArea": { + "SE4": 1771.52 + } + }, + { + "deliveryStart": "2025-01-09T09:00:00Z", + "deliveryEnd": "2025-01-09T10:00:00Z", + "entryPerArea": { + "SE4": 1675.12 + } + }, + { + "deliveryStart": "2025-01-09T10:00:00Z", + "deliveryEnd": "2025-01-09T11:00:00Z", + "entryPerArea": { + "SE4": 1621.45 + } + }, + { + "deliveryStart": "2025-01-09T11:00:00Z", + "deliveryEnd": "2025-01-09T12:00:00Z", + "entryPerArea": { + "SE4": 1481.97 + } + }, + { + "deliveryStart": "2025-01-09T12:00:00Z", + "deliveryEnd": "2025-01-09T13:00:00Z", + "entryPerArea": { + "SE4": 1400.66 + } + }, + { + "deliveryStart": "2025-01-09T13:00:00Z", + "deliveryEnd": "2025-01-09T14:00:00Z", + "entryPerArea": { + "SE4": 1433.13 + } + }, + { + "deliveryStart": "2025-01-09T14:00:00Z", + "deliveryEnd": "2025-01-09T15:00:00Z", + "entryPerArea": { + "SE4": 1495.45 + } + }, + { + "deliveryStart": "2025-01-09T15:00:00Z", + "deliveryEnd": "2025-01-09T16:00:00Z", + "entryPerArea": { + "SE4": 1512.95 + } + }, + { + "deliveryStart": "2025-01-09T16:00:00Z", + "deliveryEnd": "2025-01-09T17:00:00Z", + "entryPerArea": { + "SE4": 1547.74 + } + }, + { + "deliveryStart": "2025-01-09T17:00:00Z", + "deliveryEnd": "2025-01-09T18:00:00Z", + "entryPerArea": { + "SE4": 1590.93 + } + }, + { + "deliveryStart": "2025-01-09T18:00:00Z", + "deliveryEnd": "2025-01-09T19:00:00Z", + "entryPerArea": { + "SE4": 1449.72 + } + }, + { + "deliveryStart": "2025-01-09T19:00:00Z", + "deliveryEnd": "2025-01-09T20:00:00Z", + "entryPerArea": { + "SE4": 1375.09 + } + }, + { + "deliveryStart": "2025-01-09T20:00:00Z", + "deliveryEnd": "2025-01-09T21:00:00Z", + "entryPerArea": { + "SE4": 1180.21 + } + }, + { + "deliveryStart": "2025-01-09T21:00:00Z", + "deliveryEnd": "2025-01-09T22:00:00Z", + "entryPerArea": { + "SE4": 1115.02 + } + }, + { + "deliveryStart": "2025-01-09T22:00:00Z", + "deliveryEnd": "2025-01-09T23:00:00Z", + "entryPerArea": { + "SE4": 783.43 + } + } + ], + "blockPriceAggregates": [ + { + "blockName": "Off-peak 1", + "deliveryStart": "2025-01-08T23:00:00Z", + "deliveryEnd": "2025-01-09T07:00:00Z", + "averagePricePerArea": { + "SE4": { + "average": 604.24, + "min": 360.73, + "max": 1682.84 + } + } + }, + { + "blockName": "Peak", + "deliveryStart": "2025-01-09T07:00:00Z", + "deliveryEnd": "2025-01-09T19:00:00Z", + "averagePricePerArea": { + "SE4": { + "average": 1570.53, + "min": 1400.66, + "max": 1865.74 + } + } + }, + { + "blockName": "Off-peak 2", + "deliveryStart": "2025-01-09T19:00:00Z", + "deliveryEnd": "2025-01-09T23:00:00Z", + "averagePricePerArea": { + "SE4": { + "average": 1113.44, + "min": 783.43, + "max": 1375.09 + } + } + } + ], + "currency": "SEK", + "exchangeRate": 11.5176, + "areaStates": [ + { + "state": "Final", + "areas": [ + "SE4" + ] + } + ], + "areaAverages": [ + { + "areaCode": "SE4", + "price": 1172.25 + } + ] +} diff --git a/sunFiles/sun.json b/tests/resources/sun/sun.json similarity index 100% rename from sunFiles/sun.json rename to tests/resources/sun/sun.json diff --git a/weatherFiles/codes.json b/tests/resources/weather/codes.json similarity index 100% rename from weatherFiles/codes.json rename to tests/resources/weather/codes.json diff --git a/weatherFiles/station.json b/tests/resources/weather/station.json similarity index 100% rename from weatherFiles/station.json rename to tests/resources/weather/station.json diff --git a/weatherFiles/weather.json b/tests/resources/weather/weather.json similarity index 100% rename from weatherFiles/weather.json rename to tests/resources/weather/weather.json diff --git a/tests/test_action.py b/tests/test_action.py new file mode 100644 index 0000000..0f1d3b4 --- /dev/null +++ b/tests/test_action.py @@ -0,0 +1,55 @@ + +""" +Module containing common code for testing Marvin actions +""" + +import json +import os + +from unittest import TestCase + +import requests + +from irc2phpbb.bot import Bot + + +class ActionTest(TestCase): + """Base class with utility functions for testing marvin actions""" + strings = {} + + @classmethod + def setUpClass(cls): + with open("irc2phpbb/data/marvin_strings.json", encoding="utf-8") as f: + cls.strings = json.load(f) + + def executeAction(self, action, message): + """Execute an action for a message and return the response""" + return action(Bot.tokenize(message)) + + def assertActionOutput(self, action, message, expectedOutput): + """Call an action on message and assert expected output""" + actualOutput = self.executeAction(action, message) + self.assertEqual(actualOutput, expectedOutput) + + def assertActionSilent(self, action, message): + """Call an action with provided message and assert no output""" + self.assertActionOutput(action, message, None) + + def assertStringsOutput(self, action, message, expectedoutputKey, subkey=None): + """Call an action with provided message and assert the output is equal to DB""" + expectedOutput = self.strings.get(expectedoutputKey) + if subkey is not None: + if isinstance(expectedOutput, list): + expectedOutput = expectedOutput[subkey] + else: + expectedOutput = expectedOutput.get(subkey) + self.assertActionOutput(action, message, expectedOutput) + + + def createResponseFrom(self, directory, filename): + """Create a response object with contect as contained in the specified file""" + path = os.path.join(os.path.dirname(__file__), "resources", directory, f"{filename}.json") + with open(path, "r", encoding="UTF-8") as f: + response = requests.models.Response() + response._content = str.encode(json.dumps(json.load(f))) + return response diff --git a/tests/test_bbq.py b/tests/test_bbq.py new file mode 100644 index 0000000..d24a718 --- /dev/null +++ b/tests/test_bbq.py @@ -0,0 +1,41 @@ +""" +Tests for the Marvin BBQ action +""" + +from unittest import mock + +from datetime import date +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class BBQTest(ActionTest): + """Tests for the Marvin BBQ action""" + def assertBBQResponse(self, todaysDate, bbqDate, expectedMessageKey): + """Assert that the proper bbq message is returned, given a date""" + url = self.strings.get("barbecue").get("url") + message = self.strings.get("barbecue").get(expectedMessageKey) + if isinstance(message, list): + message = message[1] + if expectedMessageKey in ["base", "week", "eternity"]: + message = message % bbqDate + + with mock.patch("irc2phpbb.marvin_actions.datetime") as d, mock.patch("irc2phpbb.marvin_actions.random") as r: + d.date.today.return_value = todaysDate + r.randint.return_value = 1 + expected = f"{url}. {message}" + self.assertActionOutput(marvin_actions.marvinTimeToBBQ, "dags att grilla", expected) + + def testTimeToBBQSpring(self): + """Test each different output possible for spring dates""" + self.assertBBQResponse(date(2024, 5, 17), date(2024, 5, 17), "today") + self.assertBBQResponse(date(2024, 5, 16), date(2024, 5, 17), "tomorrow") + self.assertBBQResponse(date(2024, 5, 10), date(2024, 5, 17), "week") + self.assertBBQResponse(date(2024, 5, 1), date(2024, 5, 17), "base") + self.assertBBQResponse(date(2023, 10, 17), date(2024, 5, 17), "eternity") + + def testTimeToBBQAutumn(self): + """Test each different output possible for autumn dates""" + self.assertBBQResponse(date(2024, 9, 20), date(2024, 9, 20), "today") + self.assertBBQResponse(date(2024, 9, 19), date(2024, 9, 20), "tomorrow") + self.assertBBQResponse(date(2024, 9, 13), date(2024, 9, 20), "week") + self.assertBBQResponse(date(2024, 9, 4), date(2024, 9, 20), "base") diff --git a/tests/test_budord.py b/tests/test_budord.py new file mode 100644 index 0000000..e9d1165 --- /dev/null +++ b/tests/test_budord.py @@ -0,0 +1,16 @@ +""" +Tests for the Marvin Budord action +""" + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class BudordTest(ActionTest): + """Tests for the Marvin Budord action""" + def testBudord(self): + """Test that marvin knows all the commandments""" + for n, _ in enumerate(self.strings.get("budord"), 1): + self.assertStringsOutput(marvin_actions.marvinBudord, f"budord #{n}", "budord", f"{n}") + + self.assertStringsOutput(marvin_actions.marvinBudord,"visa stentavla 1", "budord", "1") + self.assertActionSilent(marvin_actions.marvinBudord, "var är stentavlan?") diff --git a/tests/test_commit.py b/tests/test_commit.py new file mode 100644 index 0000000..4b0116b --- /dev/null +++ b/tests/test_commit.py @@ -0,0 +1,42 @@ +""" +Tests for the Marvin Commit action +""" + +from unittest import mock + +import requests + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class CommitTest(ActionTest): + """Tests for the Marvin Commit action""" + def testCommitRequest(self): + """Test that marvin sends proper requests when generating commit messages""" + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + self.executeAction(marvin_actions.marvinCommit, "vad skriver man efter commit -m?") + self.assertEqual(r.get.call_args.args[0], "https://whatthecommit.com/index.txt") + + def testCommitResponse(self): + """Test that marvin properly handles responses when generating commit messages""" + message = "Secret sauce #9" + response = requests.models.Response() + response._content = str.encode(message) + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + r.get.return_value = response + expected = f"Använd detta meddelandet: '{message}'" + self.assertActionOutput(marvin_actions.marvinCommit, "commit", expected) + + def testCommitReaction(self): + """Test that marvin only generates commit messages when asked""" + self.assertActionSilent(marvin_actions.marvinCommit, "nocommit") + + + def testCommitError(self): + """Tests that marvin sends the proper message when get commit fails""" + with mock.patch("irc2phpbb.marvin_actions.requests.get", side_effect=Exception('API Down!')): + self.assertStringsOutput( + marvin_actions.marvinCommit, + "vad skriver man efter commit -m?", + "commit", + "error") diff --git a/tests/test_explainshell.py b/tests/test_explainshell.py new file mode 100644 index 0000000..b9c259f --- /dev/null +++ b/tests/test_explainshell.py @@ -0,0 +1,20 @@ +""" +Tests for the Marvin Explain Shell action +""" + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class ExplainShellTest(ActionTest): + """Tests for the Marvin Explain Shell action""" + def testExplainShell(self): + """Test that marvin can explain shell commands""" + url = "https://explainshell.com/explain?cmd=pwd" + self.assertActionOutput(marvin_actions.marvinExplainShell, "explain pwd", url) + self.assertActionOutput(marvin_actions.marvinExplainShell, "can you explain pwd", url) + self.assertActionOutput( + marvin_actions.marvinExplainShell, + "förklara pwd|grep -o $user", + f"{url}%7Cgrep+-o+%24user") + + self.assertActionSilent(marvin_actions.marvinExplainShell, "explains") diff --git a/tests/test_google.py b/tests/test_google.py new file mode 100644 index 0000000..467e82a --- /dev/null +++ b/tests/test_google.py @@ -0,0 +1,25 @@ +""" +Tests for the Marvin Google action +""" + +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class GoogleTest(ActionTest): + """Tests for the Marvin Google action""" + def testGoogle(self): + """Test that marvin can help google stuff""" + with mock.patch("irc2phpbb.marvin_actions.random") as r: + r.randint.return_value = 1 + self.assertActionOutput( + marvin_actions.marvinGoogle, + "kan du googla mos", + "LMGTFY https://www.google.se/search?q=mos") + self.assertActionOutput( + marvin_actions.marvinGoogle, + "kan du googla google mos", + "LMGTFY https://www.google.se/search?q=google+mos") + self.assertActionSilent(marvin_actions.marvinGoogle, "du kan googla") + self.assertActionSilent(marvin_actions.marvinGoogle, "gogool") diff --git a/tests/test_hello.py b/tests/test_hello.py new file mode 100644 index 0000000..6de347a --- /dev/null +++ b/tests/test_hello.py @@ -0,0 +1,20 @@ +""" +Tests for the Marvin Hello action +""" + +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class HelloTest(ActionTest): + """Tests for the Marvin Hello action""" + def testSayHello(self): + """Test that marvin responds to greetings""" + with mock.patch("irc2phpbb.marvin_actions.random") as r: + for skey, s in enumerate(self.strings.get("smile")): + for hkey, h in enumerate(self.strings.get("hello")): + for fkey, f in enumerate(self.strings.get("friendly")): + r.randint.side_effect = [skey, hkey, fkey] + self.assertActionOutput(marvin_actions.marvinSayHi, "hej", f"{s} {h} {f}") + self.assertActionSilent(marvin_actions.marvinSayHi, "korsning") diff --git a/tests/test_help.py b/tests/test_help.py new file mode 100644 index 0000000..a6f40f8 --- /dev/null +++ b/tests/test_help.py @@ -0,0 +1,13 @@ +""" +Tests for the Marvin Help action +""" + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class HelpTest(ActionTest): + """Tests for the Marvin Help action""" + def testHelp(self): + """Test that marvin can provide a help menu""" + self.assertStringsOutput(marvin_actions.marvinHelp, "help", "menu") + self.assertActionSilent(marvin_actions.marvinHelp, "halp") diff --git a/tests/test_joke.py b/tests/test_joke.py new file mode 100644 index 0000000..e4dad1c --- /dev/null +++ b/tests/test_joke.py @@ -0,0 +1,34 @@ +""" +Tests for the Marvin Joke action +""" + +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class JokeTest(ActionTest): + """Tests for the Marvin Joke action""" + def assertJokeOutput(self, exampleFile, expectedOutput): + """Assert that a joke is returned, given an input file""" + response = self.createResponseFrom("joke", exampleFile) + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + r.get.return_value = response + self.assertActionOutput(marvin_actions.marvinJoke, "joke", expectedOutput) + + def testJokeRequest(self): + """Test that marvin sends a proper request for a joke""" + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + self.executeAction(marvin_actions.marvinJoke, "joke") + self.assertEqual( + r.get.call_args.args[0], + "https://api.chucknorris.io/jokes/random?category=dev") + + def testJoke(self): + """Test that marvin sends a joke when requested""" + self.assertJokeOutput("joke", "There is no Esc key on Chuck Norris' keyboard, because no one escapes Chuck Norris.") + + def testJokeError(self): + """Tests that marvin returns the proper error message when joke API is down""" + with mock.patch("irc2phpbb.marvin_actions.requests.get", side_effect=Exception("API Down!")): + self.assertStringsOutput(marvin_actions.marvinJoke, "kör ett skämt", "joke", "error") diff --git a/tests/test_lunch.py b/tests/test_lunch.py new file mode 100644 index 0000000..06f5d0a --- /dev/null +++ b/tests/test_lunch.py @@ -0,0 +1,24 @@ +""" +Tests for the Marvin Lunch action +""" + +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class LunchTest(ActionTest): + """Tests for the Marvin Lunch action""" + def testLunchLocations(self): + """Test that marvin can provide lunch suggestions for certain places""" + locations = ["karlskrona", "goteborg", "angelholm", "hassleholm", "malmo"] + with mock.patch("irc2phpbb.marvin_actions.random") as r: + for location in locations: + for i, place in enumerate(self.strings.get("lunch").get("location").get(location)): + r.randint.side_effect = [0, i] + self.assertActionOutput( + marvin_actions.marvinLunch, f"mat {location}", f"Ska vi ta {place}?") + r.randint.side_effect = [1, 2] + self.assertActionOutput( + marvin_actions.marvinLunch, "dags att luncha", "Jag är lite sugen på Indiska?") + self.assertActionSilent(marvin_actions.marvinLunch, "matdags") diff --git a/test_main.py b/tests/test_main.py similarity index 94% rename from test_main.py rename to tests/test_main.py index ed8c0bb..82dc62f 100644 --- a/test_main.py +++ b/tests/test_main.py @@ -12,17 +12,16 @@ import sys from unittest import TestCase -from main import mergeOptionsWithConfigFile, parseOptions, determineProtocol, MSG_VERSION, createBot -from irc_bot import IrcBot -from discord_bot import DiscordBot +from irc2phpbb.__main__ import mergeOptionsWithConfigFile, parseOptions, determineProtocol, MSG_VERSION, createBot +from irc2phpbb.irc_bot import IrcBot +from irc2phpbb.discord_bot import DiscordBot class ConfigMergeTest(TestCase): """Test merging a config file with a dict""" - def assertMergedConfig(self, config, fileName, expected): """Merge dict with file and assert the result matches expected""" - configFile = os.path.join("testConfigs", f"{fileName}.json") + configFile = os.path.join(os.path.dirname(__file__), "resources", "config", f"{fileName}.json") actualConfig = mergeOptionsWithConfigFile(config, configFile) self.assertEqual(actualConfig, expected) @@ -65,6 +64,8 @@ def testAddSingleParameterMerges(self): class ConfigParseTest(TestCase): """Test parsing options into a config""" + TEST_CONFIG_DIR = os.path.join(os.path.dirname(__file__), "resources", "config") + SAMPLE_CONFIG = { "server": "localhost", "port": 6667, @@ -99,7 +100,7 @@ def testOverrideMultipleParameters(self): def testOverrideWithFile(self): """Test that parameters can be overridden with the --config option""" - configFile = os.path.join("testConfigs", "server.json") + configFile = os.path.join(self.TEST_CONFIG_DIR, "server.json") sys.argv = ["./main.py", "--config", configFile] actual = parseOptions(self.SAMPLE_CONFIG) self.assertEqual(actual.get("server"), "irc.dbwebb.se") @@ -108,7 +109,7 @@ def testOverridePrecedenceConfigFirst(self): """Test that proper precedence is considered. From most to least significant it should be: explicit parameter -> parameter in --config file -> default """ - configFile = os.path.join("testConfigs", "server.json") + configFile = os.path.join(self.TEST_CONFIG_DIR, "server.json") sys.argv = ["./main.py", "--config", configFile, "--server", "important.com"] actual = parseOptions(self.SAMPLE_CONFIG) self.assertEqual(actual.get("server"), "important.com") @@ -117,7 +118,7 @@ def testOverridePrecedenceParameterFirst(self): """Test that proper precedence is considered. From most to least significant it should be: explicit parameter -> parameter in --config file -> default """ - configFile = os.path.join("testConfigs", "server.json") + configFile = os.path.join(self.TEST_CONFIG_DIR, "server.json") sys.argv = ["./main.py", "--server", "important.com", "--config", configFile] actual = parseOptions(self.SAMPLE_CONFIG) self.assertEqual(actual.get("server"), "important.com") diff --git a/tests/test_morning.py b/tests/test_morning.py new file mode 100644 index 0000000..c5db2f5 --- /dev/null +++ b/tests/test_morning.py @@ -0,0 +1,25 @@ +""" +Tests for the Marvin Morning action +""" + +from datetime import date +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_general_actions + +class MorningTest(ActionTest): + """Tests for the Marvin Morning action""" + def testMorning(self): + """Test that marvin wishes good morning, at most once per day""" + marvin_general_actions.lastDateGreeted = None + with mock.patch("irc2phpbb.marvin_general_actions.datetime") as d: + d.date.today.return_value = date(2024, 5, 17) + with mock.patch("irc2phpbb.marvin_general_actions.random") as r: + r.choice.return_value = "Morgon" + self.assertActionOutput(marvin_general_actions.marvinMorning, "morrn", "Morgon") + # Should only greet once per day + self.assertActionSilent(marvin_general_actions.marvinMorning, "morgon") + # Should greet again tomorrow + d.date.today.return_value = date(2024, 5, 18) + self.assertActionOutput(marvin_general_actions.marvinMorning, "godmorgon", "Morgon") diff --git a/tests/test_nameday.py b/tests/test_nameday.py new file mode 100644 index 0000000..514091f --- /dev/null +++ b/tests/test_nameday.py @@ -0,0 +1,47 @@ +""" +Tests for the Marvin NameDay action +""" + +from unittest import mock + +from datetime import date +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class NameDayTest(ActionTest): + """Tests for the Marvin Joke action""" + def assertNameDayOutput(self, exampleFile, expectedOutput): + """Assert that the proper nameday message is returned, given an inputfile""" + response = self.createResponseFrom("nameday", exampleFile) + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + r.get.return_value = response + self.assertActionOutput(marvin_actions.marvinNameday, "nameday", expectedOutput) + + def testNameDayReaction(self): + """Test that marvin only responds to nameday when asked""" + self.assertActionSilent(marvin_actions.marvinNameday, "anything") + + def testNameDayRequest(self): + """Test that marvin sends a proper request for nameday info""" + with mock.patch("irc2phpbb.marvin_actions.requests") as r, mock.patch("irc2phpbb.marvin_actions.datetime") as d: + d.datetime.now.return_value = date(2024, 1, 2) + self.executeAction(marvin_actions.marvinNameday, "namnsdag") + self.assertEqual(r.get.call_args.args[0], "https://api.dryg.net/dagar/v2.1/2024/1/2") + + def testNameDayResponse(self): + """Test that marvin properly parses nameday responses""" + self.assertNameDayOutput("single", "Idag har Svea namnsdag") + self.assertNameDayOutput("double", "Idag har Alfred och Alfrida namnsdag") + self.assertNameDayOutput("triple", "Idag har Kasper, Melker och Baltsar namnsdag") + self.assertNameDayOutput("nobody", "Ingen har namnsdag idag") + + def testNameDayError(self): + """Tests that marvin returns the proper error message when nameday API is down""" + with mock.patch("irc2phpbb.marvin_actions.requests.get", side_effect=Exception("API Down!")): + self.assertStringsOutput( + marvin_actions.marvinNameday, + "har någon namnsdag idag?", + "nameday", + "error") + + diff --git a/tests/test_powerprice.py b/tests/test_powerprice.py new file mode 100644 index 0000000..753392f --- /dev/null +++ b/tests/test_powerprice.py @@ -0,0 +1,44 @@ +""" +Tests for the Marvin Power Price action +""" + +import datetime + +from datetime import date, time + +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class PowerPriceTest(ActionTest): + """Tests for the Marvin Power Price action""" + def testPowerPriceRequest(self): + """Test that marvin sends the expected request for power price info""" + with mock.patch("irc2phpbb.marvin_actions.datetime", wraps=datetime) as d: + d.datetime.today.return_value = date(2025, 1, 12) + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + self.executeAction(marvin_actions.marvinPowerPrice, "elpris") + expectedUrl = self.strings.get("powerprice").get("url").format("2025-01-12") + self.assertEqual(r.get.call_args.args[0], expectedUrl) + + def testPowerPriceResponse(self): + """Test that marvin properly parses weather responses""" + with mock.patch("irc2phpbb.marvin_actions.random") as r: + r.randint.return_value = 0 + self.assertPowerPriceOutput("singleAreaResponse", time(12, 1, 0, 0), "Just nu kostar en kWh 1.4007 SEK i SE4.") + self.assertPowerPriceOutput("singleAreaResponse", time(15, 0, 1, 0), "Just nu kostar en kWh 1.5130 SEK i SE4. Huvva!") + + + def testPowerPriceReaction(self): + """Test that marvin only reacts to power price requests when asked""" + self.assertActionSilent(marvin_actions.marvinPowerPrice, "strömmen är dyr idag!") + + def assertPowerPriceOutput(self, exampleFile, timeOfDay, expectedOutput): + """Assert that marvin knows the current power price, given an input file and a time (in UTC)""" + response = self.createResponseFrom("powerPriceFiles", exampleFile) + with mock.patch("irc2phpbb.marvin_actions.datetime", wraps=datetime) as d: + d.datetime.utcnow.return_value = timeOfDay + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + r.get.return_value = response + self.assertActionOutput(marvin_actions.marvinPowerPrice, "elpris", expectedOutput) diff --git a/tests/test_principle.py b/tests/test_principle.py new file mode 100644 index 0000000..4eb56b9 --- /dev/null +++ b/tests/test_principle.py @@ -0,0 +1,20 @@ +""" +Tests for the Marvin Principle action +""" + +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class PrincipleTest(ActionTest): + """Tests for the Marvin Principle action""" + def testPrinciple(self): + """Test that marvin can recite some software principles""" + principles = self.strings.get("principle") + for key, value in principles.items(): + self.assertActionOutput(marvin_actions.marvinPrinciple, f"princip {key}", value) + with mock.patch("irc2phpbb.marvin_actions.random") as r: + r.choice.return_value = "dry" + self.assertStringsOutput(marvin_actions.marvinPrinciple, "princip", "principle", "dry") + self.assertActionSilent(marvin_actions.marvinPrinciple, "principlös") diff --git a/tests/test_quote.py b/tests/test_quote.py new file mode 100644 index 0000000..220436c --- /dev/null +++ b/tests/test_quote.py @@ -0,0 +1,23 @@ +""" +Tests for the Marvin Quote action +""" + +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class QuoteTest(ActionTest): + """Tests for the Marvin Quote action""" + def testQuote(self): + """Test that marvin can quote The Hitchhikers Guide to the Galaxy""" + with mock.patch("irc2phpbb.marvin_actions.random") as r: + r.randint.return_value = 1 + self.assertStringsOutput(marvin_actions.marvinQuote, "ge os ett citat", "hitchhiker", 1) + self.assertStringsOutput(marvin_actions.marvinQuote, "filosofi", "hitchhiker", 1) + self.assertStringsOutput(marvin_actions.marvinQuote, "filosofera", "hitchhiker", 1) + self.assertActionSilent(marvin_actions.marvinQuote, "noquote") + + for i,_ in enumerate(self.strings.get("hitchhiker")): + r.randint.return_value = i + self.assertStringsOutput(marvin_actions.marvinQuote, "quote", "hitchhiker", i) diff --git a/tests/test_smile.py b/tests/test_smile.py new file mode 100644 index 0000000..dc77a89 --- /dev/null +++ b/tests/test_smile.py @@ -0,0 +1,17 @@ +""" +Tests for the Marvin Smile action +""" + +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class SmileTest(ActionTest): + """Tests for the Marvin Smile action""" + def testSmile(self): + """Test that marvin can smile""" + with mock.patch("irc2phpbb.marvin_actions.random") as r: + r.randint.return_value = 1 + self.assertStringsOutput(marvin_actions.marvinSmile, "le lite?", "smile", 1) + self.assertActionSilent(marvin_actions.marvinSmile, "sur idag?") diff --git a/tests/test_source.py b/tests/test_source.py new file mode 100644 index 0000000..da3677d --- /dev/null +++ b/tests/test_source.py @@ -0,0 +1,14 @@ +""" +Tests for the Marvin Source action +""" + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class SourceTest(ActionTest): + """Tests for the Marvin Source action""" + def testSource(self): + """Test that marvin responds to questions about source code""" + self.assertStringsOutput(marvin_actions.marvinSource, "source", "source") + self.assertStringsOutput(marvin_actions.marvinSource, "källkod", "source") + self.assertActionSilent(marvin_actions.marvinSource, "opensource") diff --git a/tests/test_stream.py b/tests/test_stream.py new file mode 100644 index 0000000..56a272b --- /dev/null +++ b/tests/test_stream.py @@ -0,0 +1,13 @@ +""" +Tests for the Marvin Stream action +""" + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class StreamTest(ActionTest): + """Tests for the Marvin Stream action""" + def testStream(self): + """Test that marvin can provide the link to the stream""" + self.assertStringsOutput(marvin_actions.marvinStream, "ska mos streama?", "stream", "info") + self.assertActionSilent(marvin_actions.marvinStream, "är mos en streamer?") diff --git a/tests/test_strip.py b/tests/test_strip.py new file mode 100644 index 0000000..77e95f6 --- /dev/null +++ b/tests/test_strip.py @@ -0,0 +1,25 @@ +""" +Tests for the Marvin Comic Strip action +""" + +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class StripTest(ActionTest): + """Tests for the Marvin Comic Strip action""" + def testStrip(self): + """Test that marvin can recommend comics""" + messageFormat = self.strings.get("commitstrip").get("message") + expected = messageFormat.format(url=self.strings.get("commitstrip").get("url")) + self.assertActionOutput(marvin_actions.marvinStrip, "lite strip kanske?", expected) + self.assertActionSilent(marvin_actions.marvinStrip, "nostrip") + + def testRandomStrip(self): + """Test that marvin can recommend random comics""" + messageFormat = self.strings.get("commitstrip").get("message") + expected = messageFormat.format(url=self.strings.get("commitstrip").get("urlPage") + "123") + with mock.patch("irc2phpbb.marvin_actions.random") as r: + r.randint.return_value = 123 + self.assertActionOutput(marvin_actions.marvinStrip, "random strip kanske?", expected) diff --git a/tests/test_sun.py b/tests/test_sun.py new file mode 100644 index 0000000..afd8836 --- /dev/null +++ b/tests/test_sun.py @@ -0,0 +1,27 @@ +""" +Tests for the Marvin Sun action +""" + +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class sunTest(ActionTest): + """Tests for the Marvin Sun action""" + def assertSunOutput(self, expectedOutput): + """Test that marvin knows when the sun comes up, given an input file""" + response = self.createResponseFrom("sun", "sun") + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + r.get.return_value = response + self.assertActionOutput(marvin_actions.marvinSun, "sol", expectedOutput) + + def testSun(self): + """Test that marvin sends the sunrise and sunset times """ + self.assertSunOutput( + "Idag går solen upp 7:12 och ner 18:21. Iallafall i trakterna kring BTH.") + + def testSunError(self): + """Tests that marvin returns the proper error message when joke API is down""" + with mock.patch("irc2phpbb.marvin_actions.requests.get", side_effect=Exception("API Down!")): + self.assertStringsOutput(marvin_actions.marvinSun, "när går solen ner?", "sun", "error") diff --git a/tests/test_uptime.py b/tests/test_uptime.py new file mode 100644 index 0000000..ec179c2 --- /dev/null +++ b/tests/test_uptime.py @@ -0,0 +1,13 @@ +""" +Tests for the Marvin Uptime action +""" + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class UptimeTest(ActionTest): + """Tests for the Marvin Uptime action""" + def testUptime(self): + """Test that marvin can provide the link to the uptime tournament""" + self.assertStringsOutput(marvin_actions.marvinUptime, "visa lite uptime", "uptime", "info") + self.assertActionSilent(marvin_actions.marvinUptime, "uptimetävling") diff --git a/tests/test_video.py b/tests/test_video.py new file mode 100644 index 0000000..d7582ae --- /dev/null +++ b/tests/test_video.py @@ -0,0 +1,24 @@ +""" +Tests for the Marvin Video of the Day action +""" + +from datetime import date, timedelta +from unittest import mock + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class VideoOfTodayTest(ActionTest): + """Tests for the Marvin Video of the Day action""" + def testVideoOfToday(self): + """Test that marvin can link to a different video each day of the week""" + with mock.patch("irc2phpbb.marvin_actions.datetime") as dt: + for d in range(1, 8): + day = date(2024, 11, 25) + timedelta(days=d) + dt.date.today.return_value = day + weekday = day.strftime("%A") + weekdayPhrase = self.strings.get("video-of-today").get(weekday).get("message") + videoPhrase = self.strings.get("video-of-today").get(weekday).get("url") + response = f"{weekdayPhrase} En passande video är {videoPhrase}" + self.assertActionOutput(marvin_actions.marvinVideoOfToday, "dagens video", response) + self.assertActionSilent(marvin_actions.marvinVideoOfToday, "videoidag") diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..981f0f9 --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,39 @@ +""" +Tests for the Marvin Weather action +""" + +import json +import os + +from unittest import mock + +import requests + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class WeatherTest(ActionTest): + """Tests for the Marvin Weather action""" + def testWeatherRequest(self): + """Test that marvin sends the expected requests for weather info""" + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + self.executeAction(marvin_actions.marvinWeather, "väder") + for url in ["https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/13/station/65090/period/latest-hour/data.json", + "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/13/codes.json", + "https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/15.5890/lat/56.1500/data.json"]: + self.assertTrue(mock.call(url, timeout=5) in r.get.call_args_list) + + def testWeatherResponse(self): + """Test that marvin properly parses weather responses""" + responses = [] + for responseFile in ["station.json", "codes.json", "weather.json"]: + path = os.path.join(os.path.dirname(__file__), "resources", "weather", responseFile) + with open(path, "r", encoding="UTF-8") as f: + response = requests.models.Response() + response._content = str.encode(json.dumps(json.load(f))) + responses.append(response) + + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + r.get.side_effect = responses + expected = "Karlskrona just nu: 11.7 °C. Inget signifikant väder observerat." + self.assertActionOutput(marvin_actions.marvinWeather, "väder", expected) diff --git a/tests/test_whois.py b/tests/test_whois.py new file mode 100644 index 0000000..df7c530 --- /dev/null +++ b/tests/test_whois.py @@ -0,0 +1,13 @@ +""" +Tests for the Marvin Whois action +""" + +from test_action import ActionTest +from irc2phpbb import marvin_actions + +class WhoisTest(ActionTest): + """Tests for the Marvin Whois action""" + def testWhois(self): + """Test that marvin responds to whois""" + self.assertStringsOutput(marvin_actions.marvinWhoIs, "vem är marvin?", "whois") + self.assertActionSilent(marvin_actions.marvinWhoIs, "vemär")