IQ.Pilot Release Commit @ a209cd3

This commit is contained in:
IQ.Lvbs CI [bot]
2026-08-12 02:48:02 -05:00
commit 6c7d9fd435
4560 changed files with 1231277 additions and 0 deletions

9
iqpilot/selfdrive/ui/tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
test
test_translations
test_ui/report_1
test_ui/raylib_report
diff/*.mp4
diff/*.html
diff/.coverage
diff/htmlcov/

View File

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import os
import sys
import time
import json
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.params import Params
from iqpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
from iqpilot.system.updated.updated import parse_release_notes
if __name__ == "__main__":
params = Params()
with open(os.path.join(BASEDIR, "iqpilot/selfdrive/selfdrived/alerts_offroad.json")) as f:
offroad_alerts = json.load(f)
t = 10 if len(sys.argv) < 2 else int(sys.argv[1])
while True:
print("setting alert update")
params.put_bool("UpdateAvailable", True)
params.put("UpdaterNewReleaseNotes", parse_release_notes(BASEDIR))
time.sleep(t)
params.put_bool("UpdateAvailable", False)
# cycle through normal alerts
for a in offroad_alerts:
print("setting alert:", a)
set_offroad_alert(a, True)
time.sleep(t)
set_offroad_alert(a, False)
print("no alert")
time.sleep(t)

View File

@@ -0,0 +1,53 @@
import pytest
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal import car
from iqpilot.common.params import Params
from iqpilot.system.manager.process_config import managed_processes
@pytest.mark.skip("tmp disabled")
class TestFeedbackd:
def setup_method(self):
self.pm = messaging.PubMaster(['carState', 'rawAudioData'])
self.sm = messaging.SubMaster(['audioFeedback'])
def _send_lkas_button(self, pressed: bool):
msg = messaging.new_message('carState')
msg.carState.canValid = True
msg.carState.buttonEvents = [{'type': car.CarState.ButtonEvent.Type.lkas, 'pressed': pressed}]
self.pm.send('carState', msg)
def _send_audio_data(self, count: int = 5):
for _ in range(count):
audio_msg = messaging.new_message('rawAudioData')
audio_msg.rawAudioData.data = bytes(1600) # 800 samples of int16
audio_msg.rawAudioData.sampleRate = 16000
self.pm.send('rawAudioData', audio_msg)
self.sm.update(timeout=100)
@pytest.mark.parametrize("record_feedback", [False, True])
def test_audio_feedback(self, record_feedback):
Params().put_bool("RecordAudioFeedback", record_feedback)
managed_processes["feedbackd"].start()
assert self.pm.wait_for_readers_to_update('carState', timeout=5)
assert self.pm.wait_for_readers_to_update('rawAudioData', timeout=5)
self._send_lkas_button(pressed=True)
self._send_audio_data()
self._send_lkas_button(pressed=False)
self._send_audio_data()
if record_feedback:
assert self.sm.updated['audioFeedback'], "audioFeedback should be published when enabled"
else:
assert not self.sm.updated['audioFeedback'], "audioFeedback should not be published when disabled"
self._send_lkas_button(pressed=True)
self._send_audio_data()
self._send_lkas_button(pressed=False)
self._send_audio_data()
assert not self.sm.updated['audioFeedback'], "audioFeedback should not be published after second press"
managed_processes["feedbackd"].stop()

View File

@@ -0,0 +1,39 @@
from pathlib import Path
from iqpilot.selfdrive.ui.lib.local_routes import list_local_routes
def _touch(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"")
def test_list_local_routes_from_segment_directories(tmp_path):
route_name = "aaaaaaaaaaaaaaaa|2026-07-03--12-30-00"
_touch(tmp_path / f"{route_name}--0" / "fcamera.hevc")
_touch(tmp_path / f"{route_name}--1" / "rlog.zst")
routes = list_local_routes(tmp_path)
assert len(routes) == 1
assert routes[0].name == route_name
assert routes[0].segment_count == 2
assert routes[0].camera_count == 1
assert "Jul 3" in routes[0].label
def test_list_local_routes_from_nested_route_directory(tmp_path):
route_name = "bbbbbbbbbbbbbbbb|2026-07-03--13-45-00"
_touch(tmp_path / route_name / "0" / "fcamera.hevc")
routes = list_local_routes(tmp_path)
assert len(routes) == 1
assert routes[0].name == route_name
assert routes[0].subtitle == "1 segment - road camera"
def test_list_local_routes_ignores_invalid_entries(tmp_path):
_touch(tmp_path / "not-a-route" / "fcamera.hevc")
assert list_local_routes(tmp_path) == []

View File

@@ -0,0 +1,31 @@
from types import SimpleNamespace
from iqpilot.selfdrive.ui.lib import motd
def test_load_motds_uses_verified_module_and_dongle_override(monkeypatch):
calls = []
def import_verified_module(bundle, module):
calls.append((bundle, module))
return SimpleNamespace(messages_for_dongle=lambda dongle_id: (" Staff fleet ", "", 42))
monkeypatch.setattr(
"iqpilot.system.proprietary_runtime._verified_import.import_verified_module",
import_verified_module,
)
assert motd.load_motds("0123456789ABCDEF") == ["Staff fleet"]
assert calls == [(motd._BUNDLE_NAME, motd._MODULE_NAME)]
def test_load_motds_falls_back_when_verified_import_is_unavailable(monkeypatch):
def fail_import(*_args):
raise ImportError("bundle unavailable")
monkeypatch.setattr(
"iqpilot.system.proprietary_runtime._verified_import.import_verified_module",
fail_import,
)
assert motd.load_motds("0123456789abcdef") == list(motd.FALLBACK_MOTDS)

View File

@@ -0,0 +1,71 @@
import json
from iqpilot.common.params import Params
from iqpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position, resolve_mapbox_token
def test_resolve_mapbox_token_reads_param(tmp_path):
params = Params(tmp_path.as_posix())
params.put("MapboxToken", "pk.test-token")
assert resolve_mapbox_token(params) == "pk.test-token"
def test_resolve_mapbox_token_missing_returns_empty(tmp_path):
params = Params(tmp_path.as_posix())
assert resolve_mapbox_token(params) == ""
def test_current_or_last_gps_position_uses_last_position_param(tmp_path):
params = Params(tmp_path.as_posix())
params.put("LastGPSPosition", json.dumps({
"latitude": 37.7749,
"longitude": -122.4194,
"bearing": 91.5,
}))
lat, lon, bearing, valid = current_or_last_gps_position(params)
assert valid
assert lat == 37.7749
assert lon == -122.4194
assert bearing == 91.5
def test_current_or_last_gps_position_uses_iqloc_position_param(tmp_path):
params = Params(tmp_path.as_posix())
params.put("LastGPSPositionIQLoc", json.dumps({
"latitude": 34.0522,
"longitude": -118.2437,
"bearingDeg": 12.0,
}))
lat, lon, bearing, valid = current_or_last_gps_position(params)
assert valid
assert lat == 34.0522
assert lon == -118.2437
assert bearing == 12.0
def test_current_or_last_gps_position_accepts_lat_lon_aliases(tmp_path):
params = Params(tmp_path.as_posix())
params.put("LastGPSPosition", json.dumps({
"lat": 40.7128,
"lng": -74.006,
}))
lat, lon, bearing, valid = current_or_last_gps_position(params)
assert valid
assert lat == 40.7128
assert lon == -74.006
assert bearing == 0.0
def test_current_or_last_gps_position_rejects_zero_position(tmp_path):
params = Params(tmp_path.as_posix())
params.put("LastGPSPosition", "{}")
assert current_or_last_gps_position(params) == (0.0, 0.0, 0.0, False)

View File

@@ -0,0 +1,72 @@
from types import SimpleNamespace
from iqpilot.ui.onroad.nav_map_utils import (
build_mapbox_static_url,
build_mapbox_tile_url,
choose_nav_camera,
mercator_world_px,
mercator_world_px_at_zoom,
project_nav_point,
project_nav_polyline,
tile_world_size,
)
def test_mercator_world_px_changes_with_longitude():
x1, y1 = mercator_world_px(41.8826, -87.6393, 16.0)
x2, y2 = mercator_world_px(41.8826, -87.6293, 16.0)
assert x2 > x1
assert abs(y2 - y1) < 1.0
def test_project_nav_point_centers_current_position():
x, y = project_nav_point(41.8826, -87.6393, 41.8826, -87.6393, 16.0, 90.0, 420.0, 420.0)
assert round(x, 3) == 210.0
assert round(y, 3) == 210.0
def test_project_nav_polyline_preserves_point_count():
points = [
SimpleNamespace(latitude=41.8826, longitude=-87.6422),
SimpleNamespace(latitude=41.8826, longitude=-87.6393),
SimpleNamespace(latitude=41.8830, longitude=-87.6366),
]
projected = project_nav_polyline(points, 41.8826, -87.6393, 16.0, 90.0, 420.0, 420.0)
assert len(projected) == len(points)
def test_choose_nav_camera_looks_ahead_of_vehicle():
points = [
SimpleNamespace(latitude=41.8826, longitude=-87.6393),
SimpleNamespace(latitude=41.8835, longitude=-87.6355),
]
center_lat, center_lon, zoom = choose_nav_camera(41.8826, -87.6393, 90.0, points, 420.0, 420.0, 16.2)
assert center_lon > -87.6393
assert 16.0 <= zoom <= 17.8
def test_build_mapbox_static_url_contains_expected_components():
url = build_mapbox_static_url(41.8826, -87.6393, 16.2, 90.0, 420, 420)
assert "navigation-day-v1/static/" in url
assert "-87.639300,41.882600,16.20,90.0,0/420x420@2x" in url
def test_build_mapbox_tile_url_contains_expected_components():
url = build_mapbox_tile_url(16, 10619, 24322)
assert "navigation-day-v1/tiles/256/16/10619/24322@2x" in url
def test_world_size_and_world_px_align_at_integer_zoom():
world_size = tile_world_size(16)
x, y = mercator_world_px_at_zoom(41.8826, -87.6393, 16)
assert 0.0 <= x <= world_size
assert 0.0 <= y <= world_size

View File

@@ -0,0 +1,39 @@
import sqlite3
from scripts.iqpilot.package_xyz_tiles_to_mbtiles import build_mbtiles
from scripts.iqpilot.render_raster_tiles_from_vector_mbtiles import tile_range_for_bounds
PNG_1X1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xf8\xcf"
b"\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99=\x1d\x00\x00\x00\x00IEND\xaeB`\x82"
)
def test_tile_range_for_bounds_returns_non_empty_ranges():
x_range, y_range = tile_range_for_bounds((-88.15, 41.65, -88.02, 41.76), 14)
assert len(list(x_range)) > 0
assert len(list(y_range)) > 0
def test_build_mbtiles_from_xyz_tiles(tmp_path):
source = tmp_path / "xyz"
tile_dir = source / "14" / "2625"
tile_dir.mkdir(parents=True)
(tile_dir / "6335@2x.png").write_bytes(PNG_1X1)
output = tmp_path / "offline.mbtiles"
build_mbtiles(source, output, bounds="-88.15,41.65,-88.02,41.76")
conn = sqlite3.connect(output)
try:
fmt = conn.execute("SELECT value FROM metadata WHERE name='format'").fetchone()[0]
bounds = conn.execute("SELECT value FROM metadata WHERE name='bounds'").fetchone()[0]
count = conn.execute("SELECT COUNT(*) FROM tiles").fetchone()[0]
finally:
conn.close()
assert fmt == "png"
assert bounds == "-88.15,41.65,-88.02,41.76"
assert count == 1

View File

@@ -0,0 +1,128 @@
import os
import sqlite3
import json
from iqpilot.ui.onroad import offline_tiles
PNG_1X1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xf8\xcf"
b"\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99=\x1d\x00\x00\x00\x00IEND\xaeB`\x82"
)
def _write_mbtiles(path):
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE metadata (name text, value text)")
conn.execute("CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)")
conn.execute("INSERT INTO metadata (name, value) VALUES ('format', 'png')")
conn.execute("INSERT INTO metadata (name, value) VALUES ('minzoom', '1')")
conn.execute("INSERT INTO metadata (name, value) VALUES ('maxzoom', '3')")
conn.execute(
"INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)",
(1, 1, offline_tiles.xyz_to_tms_y(1, 0), PNG_1X1),
)
conn.commit()
conn.close()
def test_xyz_to_tms_y():
assert offline_tiles.xyz_to_tms_y(1, 0) == 1
assert offline_tiles.xyz_to_tms_y(1, 1) == 0
def test_find_offline_mbtiles_path_from_env(tmp_path, monkeypatch):
mbtiles = tmp_path / "demo.mbtiles"
_write_mbtiles(mbtiles)
monkeypatch.setenv(offline_tiles.OFFLINE_MBTILES_ENV, str(mbtiles))
assert offline_tiles.find_offline_mbtiles_path() == mbtiles
def test_find_offline_xyz_root(tmp_path, monkeypatch):
root = tmp_path / "tiles"
(root / "15" / "10500").mkdir(parents=True)
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root))
assert offline_tiles.find_offline_xyz_root() == root
def test_load_raster_tile_blob_from_mbtiles(tmp_path):
mbtiles = tmp_path / "offline.mbtiles"
_write_mbtiles(mbtiles)
conn = offline_tiles.open_mbtiles(mbtiles)
try:
assert offline_tiles.mbtiles_is_raster(conn) is True
assert offline_tiles.mbtiles_zoom_bounds(conn) == (1, 3)
assert offline_tiles.load_raster_tile_blob(conn, 1, 1, 0) == PNG_1X1
finally:
conn.close()
def test_load_raster_tile_blob_from_xyz_dir(tmp_path, monkeypatch):
root = tmp_path / "offline_tiles"
tile_path = root / "14" / "2625"
tile_path.mkdir(parents=True)
(tile_path / "6335@2x.png").write_bytes(PNG_1X1)
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root))
assert offline_tiles.xyz_zoom_bounds(root) == (14, 14)
assert offline_tiles.load_raster_xyz_tile_blob(root, 14, 2625, 6335) == PNG_1X1
def test_find_offline_region_root_by_bounds(tmp_path, monkeypatch):
root = tmp_path / "offline_maps"
il = root / "regions" / "illinois"
ca = root / "regions" / "california"
(il / "tiles").mkdir(parents=True)
(ca / "tiles").mkdir(parents=True)
(il / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-91.6,36.9,-87.4,42.6"}}))
(ca / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-124.5,32.4,-114.1,42.1"}}))
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root / "tiles"))
assert offline_tiles.find_offline_region_root(41.88, -87.63) == il
assert offline_tiles.find_offline_region_root(34.05, -118.24) == ca
def test_find_offline_mbtiles_path_uses_selected_region(tmp_path, monkeypatch):
root = tmp_path / "offline_maps"
il = root / "regions" / "illinois"
ca = root / "regions" / "california"
(il / "tiles").mkdir(parents=True)
(ca / "tiles").mkdir(parents=True)
(il / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-91.6,36.9,-87.4,42.6"}}))
(ca / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-124.5,32.4,-114.1,42.1"}}))
_write_mbtiles(il / "tiles" / "offline.mbtiles")
_write_mbtiles(ca / "tiles" / "offline.mbtiles")
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root / "tiles"))
assert offline_tiles.find_offline_mbtiles_path(41.88, -87.63) == il / "tiles" / "offline.mbtiles"
assert offline_tiles.find_offline_mbtiles_path(34.05, -118.24) == ca / "tiles" / "offline.mbtiles"
def test_day_variant_selection(tmp_path, monkeypatch):
root = tmp_path / "offline_maps"
region = root / "regions" / "us_state.IL"
(region / "tiles").mkdir(parents=True)
(region / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-88.3,41.5,-87.8,41.9"}}))
_write_mbtiles(region / "tiles" / "offline.mbtiles")
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root / "tiles"))
offline_tiles._region_roots_cache = None
offline_tiles._region_bounds_cache.clear()
# no day variant yet: day request falls back to the night set
night = region / "tiles" / "offline.mbtiles"
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=True) == night
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=False) == night
# day variant installed: day requests prefer it, night unchanged
_write_mbtiles(region / "tiles" / "offline_day.mbtiles")
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=True) == region / "tiles" / "offline_day.mbtiles"
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=False) == night
def test_solar_elevation_day_night():
from iqpilot.ui.onroad.nav_map_utils import solar_elevation_deg
# Chicago 2026-07-11: 18:00 UTC (1pm CDT) is day; 06:00 UTC (1am CDT) is night
noon_utc = 1783792800.0 # 2026-07-11 18:00:00 UTC
night_utc = noon_utc - 12 * 3600
assert solar_elevation_deg(41.88, -87.63, noon_utc) > 30.0
assert solar_elevation_deg(41.88, -87.63, night_utc) < -10.0

View File

@@ -0,0 +1,8 @@
import time
from iqpilot.selfdrive.test.helpers import with_processes
@with_processes(["ui"])
def test_raylib_ui():
"""Test initialization of the UI widgets is successful."""
time.sleep(1)

View File

@@ -0,0 +1,35 @@
from iqpilot.cereal import car
from iqpilot.cereal import messaging
from iqpilot.cereal.messaging import SubMaster, PubMaster
from iqpilot.selfdrive.ui.soundd import SELFDRIVE_STATE_TIMEOUT, check_selfdrive_timeout_alert
import time
AudibleAlert = car.CarControl.HUDControl.AudibleAlert
class TestSoundd:
def test_check_selfdrive_timeout_alert(self):
sm = SubMaster(['selfdriveState'])
pm = PubMaster(['selfdriveState'])
for _ in range(100):
cs = messaging.new_message('selfdriveState')
cs.selfdriveState.enabled = True
pm.send("selfdriveState", cs)
time.sleep(0.01)
sm.update(0)
assert not check_selfdrive_timeout_alert(sm)
for _ in range(SELFDRIVE_STATE_TIMEOUT * 110):
sm.update(0)
time.sleep(0.01)
assert check_selfdrive_timeout_alert(sm)
# TODO: add test with micd for checking that soundd actually outputs sounds

View File

@@ -0,0 +1,124 @@
import pytest
import json
import os
import re
import xml.etree.ElementTree as ET
import string
import requests
from parameterized import parameterized_class
from iqpilot.system.ui.lib.multilang import TRANSLATIONS_DIR, LANGUAGES_FILE
with open(str(LANGUAGES_FILE)) as f:
translation_files = json.load(f)
UNFINISHED_TRANSLATION_TAG = "<translation type=\"unfinished\"" # non-empty translations can be marked unfinished
LOCATION_TAG = "<location "
FORMAT_ARG = re.compile("%[0-9]+")
@pytest.mark.skip("TODO: update for raylib")
@parameterized_class(("name", "file"), translation_files.items())
class TestTranslations:
name: str
file: str
@staticmethod
def _read_translation_file(path, file):
tr_file = os.path.join(path, f"{file}.ts")
with open(tr_file) as f:
return f.read()
def test_missing_translation_files(self):
assert os.path.exists(os.path.join(str(TRANSLATIONS_DIR), f"{self.file}.ts")), \
f"{self.name} has no XML translation file, run scripts/iqpilot/translations/update_translations.py"
@pytest.mark.skip("Only test unfinished translations before going to release")
def test_unfinished_translations(self):
cur_translations = self._read_translation_file(TRANSLATIONS_DIR, self.file)
assert UNFINISHED_TRANSLATION_TAG not in cur_translations, \
f"{self.file} ({self.name}) translation file has unfinished translations. Finish translations or mark them as completed in Qt Linguist"
def test_vanished_translations(self):
cur_translations = self._read_translation_file(TRANSLATIONS_DIR, self.file)
assert "<translation type=\"vanished\">" not in cur_translations, \
f"{self.file} ({self.name}) translation file has obsolete translations. Run scripts/iqpilot/translations/update_translations.py"
def test_finished_translations(self):
"""
Tests ran on each translation marked "finished"
Plural:
- that any numerus (plural) translations have all plural forms non-empty
- that the correct format specifier is used (%n)
Non-plural:
- that translation is not empty
- that translation format arguments are consistent
"""
tr_xml = ET.parse(os.path.join(TRANSLATIONS_DIR, f"{self.file}.ts"))
for context in tr_xml.getroot():
for message in context.iterfind("message"):
translation = message.find("translation")
source_text = message.find("source").text
# Do not test unfinished translations
if translation.get("type") == "unfinished":
continue
if message.get("numerus") == "yes":
numerusform = [t.text for t in translation.findall("numerusform")]
for nf in numerusform:
assert nf is not None, f"Ensure all plural translation forms are completed: {source_text}"
assert "%n" in nf, "Ensure numerus argument (%n) exists in translation."
assert FORMAT_ARG.search(nf) is None, f"Plural translations must use %n, not %1, %2, etc.: {numerusform}"
else:
assert translation.text is not None, f"Ensure translation is completed: {source_text}"
source_args = FORMAT_ARG.findall(source_text)
translation_args = FORMAT_ARG.findall(translation.text)
assert sorted(source_args) == sorted(translation_args), \
f"Ensure format arguments are consistent: `{source_text}` vs. `{translation.text}`"
def test_no_locations(self):
for line in self._read_translation_file(TRANSLATIONS_DIR, self.file).splitlines():
assert not line.strip().startswith(LOCATION_TAG), \
f"Line contains location tag: {line.strip()}, remove all line numbers."
def test_entities_error(self):
cur_translations = self._read_translation_file(TRANSLATIONS_DIR, self.file)
matches = re.findall(r'@(\w+);', cur_translations)
assert len(matches) == 0, f"The string(s) {matches} were found with '@' instead of '&'"
def test_bad_language(self):
IGNORED_WORDS = {'pédale'}
match = re.search(r'([a-zA-Z]{2,3})', self.file)
assert match, f"{self.name} - could not parse language"
try:
response = requests.get(
f"https://raw.githubusercontent.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words/master/{match.group(1)}"
)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == 429:
pytest.skip("word list rate limited")
raise
banned_words = {line.strip() for line in response.text.splitlines()}
for context in ET.parse(os.path.join(TRANSLATIONS_DIR, f"{self.file}.ts")).getroot():
for message in context.iterfind("message"):
translation = message.find("translation")
if translation.get("type") == "unfinished":
continue
translation_text = " ".join([t.text for t in translation.findall("numerusform")]) if message.get("numerus") == "yes" else translation.text
if not translation_text:
continue
words = set(translation_text.translate(str.maketrans('', '', string.punctuation + '%n')).lower().split())
bad_words_found = words & (banned_words - IGNORED_WORDS)
assert not bad_words_found, f"Bad language found in {self.name}: '{translation_text}'. Bad word(s): {', '.join(bad_words_found)}"