Add bilingual UI and improve event actions layout
Add persistent German/English switching, translated UI messages and English 12-hour times. Align desktop event actions while preserving mobile layouts and record bilingual project guidance. Include pending diary deletion exclusions. Validate both languages with nine tests.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""Run with: python -m unittest discover -s tests (from app/). No database needed."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import main
|
||||
from i18n import ENGLISH, current_language, gettext, safe_return_path, format_time, format_datetime
|
||||
|
||||
|
||||
class TranslationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.token = current_language.set('de')
|
||||
|
||||
def tearDown(self):
|
||||
current_language.reset(self.token)
|
||||
|
||||
def test_time_formats_include_midnight_and_noon(self):
|
||||
for hour, minute, english, german in (
|
||||
(0, 0, '12:00 AM', '00:00'),
|
||||
(0, 5, '12:05 AM', '00:05'),
|
||||
(9, 7, '9:07 AM', '09:07'),
|
||||
(12, 0, '12:00 PM', '12:00'),
|
||||
(20, 30, '8:30 PM', '20:30'),
|
||||
(23, 59, '11:59 PM', '23:59'),
|
||||
):
|
||||
value = datetime(2026, 9, 14, hour, minute)
|
||||
for language, expected in [('en', english), ('de', german)]:
|
||||
with self.subTest(language=language, hour=hour, minute=minute):
|
||||
current_language.set(language)
|
||||
self.assertEqual(format_time(value), expected)
|
||||
self.assertEqual(format_datetime(value), f'14.09.2026 {expected}')
|
||||
|
||||
def test_event_card_uses_selected_time_format(self):
|
||||
start = datetime(2026, 9, 14, 20, 30)
|
||||
row = (1, 'Band', start, None, 'Venue', 'Hagen', 'Deutschland', 'concert', None, 'public', False)
|
||||
for language, expected in [('en', '8:30 PM'), ('de', '20:30')]:
|
||||
current_language.set(language)
|
||||
card = main.serialize_concert_card(row)
|
||||
self.assertEqual(card['time'], expected)
|
||||
self.assertEqual(card['_start_datetime'], start)
|
||||
|
||||
def test_templates_compile_and_catalog_covers_literal_translations(self):
|
||||
for path in Path(main.BASE_DIR, 'templates').glob('*.html'):
|
||||
with self.subTest(template=path.name):
|
||||
main.templates.get_template(path.name)
|
||||
tree = main.templates.parse(path.read_text())
|
||||
from jinja2 import nodes
|
||||
for node in tree.find_all(nodes.Call):
|
||||
if isinstance(node.node, nodes.Name) and node.node.name == '_' and node.args and isinstance(node.args[0], nodes.Const):
|
||||
self.assertIn(node.args[0].value, ENGLISH)
|
||||
|
||||
def test_language_persists_and_can_be_changed_before_login(self):
|
||||
with patch.object(main, 'COOKIE_SECURE', False):
|
||||
client = TestClient(main.app)
|
||||
response = client.get('/login')
|
||||
self.assertIn('<html lang="de">', response.text)
|
||||
self.assertIn('Anmelden', response.text)
|
||||
response = client.get('/language/en?next=/login')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('<html lang="en">', response.text)
|
||||
self.assertIn('Log in', response.text)
|
||||
self.assertNotIn('Anmelden', response.text)
|
||||
self.assertIn('HttpOnly', response.history[0].headers['set-cookie'])
|
||||
self.assertEqual(response.headers['content-language'], 'en')
|
||||
self.assertIn('Cookie', response.headers['vary'])
|
||||
for path, title in [('/datenschutz', 'Privacy policy'), ('/impressum', 'Legal notice')]:
|
||||
page = client.get(path)
|
||||
self.assertIn(title, page.text)
|
||||
self.assertIn('aria-label="English" aria-current="true"', page.text)
|
||||
response = client.get('/language/de?next=/login')
|
||||
self.assertIn('Anmelden', response.text)
|
||||
self.assertIn('<html lang="de">', response.text)
|
||||
|
||||
def test_language_validation_and_safe_return(self):
|
||||
with patch.object(main, 'COOKIE_SECURE', False):
|
||||
client = TestClient(main.app)
|
||||
self.assertEqual(client.get('/language/fr').status_code, 400)
|
||||
for target in ['https://evil.example', '//evil.example', '/\\evil.example', '/\nevil', '/language/en']:
|
||||
self.assertEqual(safe_return_path(target), '/')
|
||||
target = '/events/past?q=Iron+Maiden&category=concert'
|
||||
response = client.get('/language/en', params={'next': target}, follow_redirects=False)
|
||||
self.assertEqual(response.headers['location'], target)
|
||||
|
||||
def test_translations_are_request_local(self):
|
||||
def read(language):
|
||||
with patch.object(main, 'COOKIE_SECURE', False):
|
||||
client = TestClient(main.app)
|
||||
client.cookies.set('metalcircle_language', language)
|
||||
return client.get('/login').text
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
languages = ['de', 'en'] * 8
|
||||
for language, page in zip(languages, pool.map(read, languages)):
|
||||
self.assertIn(f'<html lang="{language}">', page)
|
||||
self.assertIn('Log in' if language == 'en' else 'Anmelden', page)
|
||||
self.assertEqual(gettext('Anmelden'), 'Anmelden')
|
||||
|
||||
def test_user_content_is_escaped_and_never_translated(self):
|
||||
current_language.set('en')
|
||||
page = main.templates.get_template('_user_menu.html').render(user={
|
||||
'display_name': '<script>Nachrichten</script>', 'is_admin': True, 'notification_count': 2,
|
||||
})
|
||||
self.assertIn('<script>Nachrichten</script>', page)
|
||||
self.assertIn('My profile', page)
|
||||
self.assertIn('new message(s) or request(s)', page)
|
||||
|
||||
def test_every_page_renders_with_populated_content_in_both_languages(self):
|
||||
from jinja2 import ChainableUndefined
|
||||
import os
|
||||
env = main.templates.overlay(undefined=ChainableUndefined)
|
||||
member = dict(id=1, username='sample', display_name='Nachrichten <test>', email='sample@example.invalid', is_admin=True, notification_count=2, created_at='14.09.2026')
|
||||
event = dict(id=1, concert_id=1, artist='Nachrichten <test>', title='Nachrichten <test>', venue='Venue', city='Hagen', country='Deutschland', date='14.09.2026', time='20:00', event_type='concert', event_type_label='Konzert', visibility='friends', is_past=True, is_verified=True, concert_count=2, name='Venue', rating=4, favorite_song='Nachrichten <test>', notes='Nachrichten <test>', photos=[dict(id=1, path='/photo.jpg')], bands=[])
|
||||
badge = dict(code='founder', name='Gründer', description='Von Anfang an dabei und MetalCircle aufgebaut', earned=True, icon='⚔️', image_path='/patch.jpg')
|
||||
event['badges'] = [badge]
|
||||
event['linked_events'] = []
|
||||
profile = dict(member, attended_concerts=1)
|
||||
context = dict(user=member, users=[member], managed_user=member, concert=event, profile=profile, filters={}, stats={}, connections=dict(friends=[member], incoming=[member], outgoing=[member], blocked=[member]), badges=[badge], patch_groups=[dict(label='⚔️ Spezial-Patches', patches=[badge])], venues=[event, dict(event, id=2)], entries=[event], upcoming_concerts=[event], past_concerts=[event], events=[event], followed_bands=[dict(name='Nachrichten <test>', key='test')], followed_venues=[event], linkable_events=[event], invitable_users=[member], invited_user_ids=[1], attending_users=[member], maybe_users=[member], ticket_seekers=[member], ticket_offers=[member], attending_count=2, maybe_count=1, ticket_seeker_count=1, ticket_offer_count=1, comments=[dict(member, body='Nachrichten <test>')], is_own_profile=True, can_view_details=True, can_edit_title=True, can_edit_details=True, can_manage_access=True, can_delete=True, can_edit=True, can_manage=True, user_results=[member], friend_requests=[member], event_invitations=[dict(event, invited_by='Nachrichten <test>')], conversations=[dict(member, unread_count=1, last_message='Nachrichten <test>')], messages=[dict(body='Nachrichten <test>')])
|
||||
for language in ('de', 'en'):
|
||||
current_language.set(language)
|
||||
for path in Path(main.BASE_DIR, 'templates').glob('*.html'):
|
||||
with self.subTest(language=language, template=path.name):
|
||||
page = env.get_template(path.name).render(**context)
|
||||
if not path.name.startswith('_'):
|
||||
self.assertIn(f'<html lang="{language}">', page)
|
||||
self.assertIn('class="language-switch"', page)
|
||||
if os.environ.get('I18N_RENDER_DIR'):
|
||||
target = Path(os.environ['I18N_RENDER_DIR'], language)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
(target / path.name).write_text(page)
|
||||
|
||||
def test_server_validation_is_localized(self):
|
||||
current_language.set('en')
|
||||
with self.assertRaisesRegex(ValueError, 'Instagram') as error:
|
||||
main.normalize_instagram_url('https://evil.example/test')
|
||||
self.assertIn('Please enter', str(error.exception))
|
||||
response = main.change_language('invalid')
|
||||
self.assertEqual(response.body, b'Invalid language.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user