feat(oci): show new and installed application counts in the OCI menu entries, lined up in one column

This commit is contained in:
MacRimi
2026-11-04 19:02:03 +01:00
parent 143c7db610
commit ef00a34654
13 changed files with 231 additions and 56 deletions
+2 -1
View File
@@ -9016,5 +9016,6 @@
"⚠ Disk data will NOT be erased.": "⚠ Festplattendaten werden NICHT gelöscht.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Die Festplatte wird ausgehängt und aus /etc/fstab entfernt.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Der /etc/fstab-Eintrag wird entfernt.",
"⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt."
"⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt.",
"New applications ({count})": "Neue Anwendungen ({count})"
}
+2 -1
View File
@@ -8986,5 +8986,6 @@
"⚠ Disk data will NOT be erased.": "⚠ Los datos del disco NO se borrarán.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ El disco se desmontará y se eliminará de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Se eliminará la entrada /etc/fstab.",
"⚠ The disk will be unmounted.": "⚠ El disco se desmontará."
"⚠ The disk will be unmounted.": "⚠ El disco se desmontará.",
"New applications ({count})": "Aplicaciones nuevas ({count})"
}
+2 -1
View File
@@ -9015,5 +9015,6 @@
"⚠ Disk data will NOT be erased.": "⚠ Les données du disque ne seront PAS effacées.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Le disque sera démonté et supprimé de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ L'entrée /etc/fstab sera supprimée.",
"⚠ The disk will be unmounted.": "⚠ Le disque sera démonté."
"⚠ The disk will be unmounted.": "⚠ Le disque sera démonté.",
"New applications ({count})": "Nouvelles applications ({count})"
}
+2 -1
View File
@@ -9016,5 +9016,6 @@
"⚠ Disk data will NOT be erased.": "⚠ I dati del disco NON verranno cancellati.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Il disco verrà smontato e rimosso da /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ La voce /etc/fstab verrà rimossa.",
"⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato."
"⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato.",
"New applications ({count})": "Nuove applicazioni ({count})"
}
+2 -1
View File
@@ -9016,5 +9016,6 @@
"⚠ Disk data will NOT be erased.": "⚠ Os dados do disco NÃO serão apagados.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ O disco será desmontado e removido de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ A entrada /etc/fstab será removida.",
"⚠ The disk will be unmounted.": "⚠ O disco será desmontado."
"⚠ The disk will be unmounted.": "⚠ O disco será desmontado.",
"New applications ({count})": "Novas aplicações ({count})"
}
+2 -1
View File
@@ -9016,5 +9016,6 @@
"⚠ Disk data will NOT be erased.": "⚠ Dáta na disku sa NEVYMAŽÚ.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disk sa odpojí a odstráni z /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Záznam v /etc/fstab bude odstránený.",
"⚠ The disk will be unmounted.": "⚠ Disk bude odpojený."
"⚠ The disk will be unmounted.": "⚠ Disk bude odpojený.",
"New applications ({count})": "Nové aplikácie ({count})"
}
+2 -1
View File
@@ -9016,5 +9016,6 @@
"⚠ Disk data will NOT be erased.": "⚠ Diskdata kommer INTE att raderas.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disken kommer att avmonteras och tas bort från /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ /etc/fstab-posten kommer att tas bort.",
"⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras."
"⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras.",
"New applications ({count})": "Nya program ({count})"
}
+23 -14
View File
@@ -450,10 +450,15 @@ class CatalogRefresh:
self.recipes = visible_recipes(catalog)
self.displayed_directory = catalog.catalog_dir
baseline = read_json(self.seen_path).get("recipes")
self.baseline = baseline if isinstance(baseline, dict) else self.recipes.copy()
self.process = None
self.failed = False
self._finished = False
if isinstance(baseline, dict):
self.baseline = baseline
else:
# The first visit sets what counts as already known.
self.baseline = self.recipes.copy()
self.mark_seen()
def __enter__(self):
self.start()
@@ -480,8 +485,8 @@ class CatalogRefresh:
return True
def __exit__(self, *args):
# The worker can finish downloading after the user leaves. Its result
# is not marked seen until a later menu actually displays that catalog.
# The worker can finish downloading after the user leaves. Its new
# applications stay new until the user opens their list.
if self.process and self.process.poll() is not None:
self.process.wait()
elif self.process:
@@ -506,22 +511,26 @@ class CatalogRefresh:
self.displayed_directory = directory
return changed
def describe(self):
text = translate("Available applications: {count}").format(count=len(self.recipes))
added = len(self.recipes.keys() - self.baseline.keys())
updated = sum(self.recipes[key] != self.baseline[key] for key in self.recipes.keys() & self.baseline.keys())
text += "\n" + translate("New applications since your last visit: {count}").format(count=added)
if updated:
text += "\n" + translate("Updated recipes since your last visit: {count}").format(count=updated)
if self.failed:
text += "\n" + translate("Catalog refresh unavailable; using the local catalog.")
return text
def new_ids(self):
"""The applications added since the user last opened the list of new ones."""
return sorted(self.recipes.keys() - self.baseline.keys())
def label(self):
added = len(self.new_ids())
return (translate("New applications ({count})").format(count=added) if added
else translate("Refresh catalog"))
def wait(self):
if self.process is not None and not self._finished:
self.process.wait()
def mark_seen(self):
"""Opening the list of new applications makes them known."""
self.baseline = self.recipes.copy()
try:
write_json(self.seen_path, {"recipes": self.recipes})
except OSError:
self.failed = True
pass
if __name__ == "__main__":
+70 -9
View File
@@ -531,6 +531,25 @@ def _app_list(catalog: Catalog, ui, applications: list[dict[str, Any]], title: s
_app_detail(catalog, ui, index[selection])
def _new_applications(catalog: Catalog, ui, applications: list[dict[str, Any]], catalog_refresh) -> bool:
"""The applications added to the catalog since the user last opened this
list, ready to install. Returns whether a catalog refresh was asked for."""
options, index, header = _app_menu(applications)
options = [("refresh", translate("Refresh catalog")), ("", "")] + options
title = translate("New applications ({count})").format(count=len(applications))
# Opening the list makes them known: the entry stops counting them.
catalog_refresh.mark_seen()
selection = None
while True:
selection = ui.choose(header, options, selection, title=title, size=MENU_SIZE, colors=True)
if selection is None:
return False
if selection == "refresh":
return True
if selection in index:
_app_detail(catalog, ui, index[selection])
def _search(catalog: Catalog, ui, applications: list[dict[str, Any]]) -> None:
query = ui.ask(translate("Name or part of the description of the application"), required=False).strip()
if not query:
@@ -557,6 +576,29 @@ def _search(catalog: Catalog, ui, applications: list[dict[str, Any]]) -> None:
keep_order=True)
COUNTED_LABEL = re.compile(r"^(.*\S) \((\d+)\)$")
def _aligned(entries: list[tuple[str, str]]) -> list[tuple[str, str]]:
"""The counts of the menu in one column, two spaces past the longest entry
that has one, whatever the language: "Updates available (28)" lines up
with "(554)"."""
counted = [COUNTED_LABEL.match(label) for _, label in entries]
width = max((len(match[1]) for match in counted if match), default=0) + 2
return [(tag, f"{match[1]:<{width}}({int(match[2]):>3})" if match else label)
for (tag, label), match in zip(entries, counted)]
def _installed_count() -> int | None:
"""How many OCI containers the manage list shows, read from their saved
records. None while another operation holds the registry."""
from .management import saved_inventory
try:
return len(saved_inventory(PROJECT_ROOT))
except (BlockingIOError, OSError, ValueError):
return None
def _catalog_options(catalog):
applications = _installable(catalog.load_index()["applications"])
categories: dict[str, list[dict[str, Any]]] = {}
@@ -569,13 +611,13 @@ def _catalog_options(catalog):
category_by_index = {str(number): key for number, key in enumerate(order, 1)}
options = [
("search", translate("Search applications")),
("all", f"{translate('All applications'):<35} ({len(applications):>3})"),
("all", f"{translate('All applications')} ({len(applications)})"),
("manage", translate("Manage installed OCI applications")),
("catalog-refresh", translate("Refresh catalog")),
("refresh", translate("Refresh catalog")),
("registries", translate("Registry accounts")),
("custom", translate("Install an image that is not in the catalog")),
("", ""),
] + [(number, f"{translate(labels[key]):<35} ({len(categories[key]):>3})")
] + [(number, f"{translate(labels[key])} ({len(categories[key])})")
for number, key in category_by_index.items()]
return applications, categories, labels, category_by_index, options
@@ -590,15 +632,30 @@ def interactive(catalog: Catalog) -> int:
return 1
applications, categories, labels, category_by_index, options = _catalog_options(catalog)
selection = "search"
refresh_requested = False
with CatalogRefresh(PROJECT_ROOT, catalog) as catalog_refresh, UpdateCheck(PROJECT_ROOT) as updates:
while True:
if catalog_refresh.consume():
catalog = Catalog(PROJECT_ROOT)
applications, categories, labels, category_by_index, options = _catalog_options(catalog)
catalog_refresh.recipes = visible_recipes(catalog)
if refresh_requested:
# Only a refresh the user asked for says that it failed.
refresh_requested = False
if catalog_refresh.failed:
ui.message(translate("Catalog refresh unavailable; using the local catalog."))
state = updates.status()
entries = ReadableTags(options[:3] + [('updates', updates.label(state))] + options[3:])
text = catalog_refresh.describe() + "\n\n" + translate("Select a category or search for applications:")
# The catalog entry names the new applications when there are some.
installed = _installed_count()
shown = []
for tag, label in options:
if tag == "refresh":
label = catalog_refresh.label()
elif tag == "manage" and installed:
label = f"{label} ({installed})"
shown.append((tag, label))
entries = ReadableTags(_aligned(shown[:3] + [('updates', updates.label(state))] + shown[3:]))
text = translate("Select a category or search for applications:")
if notice := updates.notice(state):
text += '\n\n' + notice
if updates.active and not getattr(ui, 'live_refresh', False):
@@ -612,8 +669,6 @@ def interactive(catalog: Catalog) -> int:
selected = ui.choose(text, entries, selection, size=MENU_SIZE, **kwargs)
except MenuRefresh:
continue
finally:
catalog_refresh.mark_seen()
if selected is None:
return 0
selection = selected
@@ -626,8 +681,14 @@ def interactive(catalog: Catalog) -> int:
elif selection == "manage":
from .management import interactive_management
interactive_management(PROJECT_ROOT, ui)
elif selection == "catalog-refresh":
catalog_refresh.start(force=True)
elif selection == "refresh":
new = [item for item in applications if item["id"] in set(catalog_refresh.new_ids())]
if not new or _new_applications(catalog, ui, new, catalog_refresh):
# Asked for: wait for it and say if it could not be done.
catalog_refresh.start(force=True)
ui.info(translate("Refreshing the application catalog..."))
catalog_refresh.wait()
refresh_requested = True
elif selection == "updates":
if not updates.active:
if state['available']:
+91
View File
@@ -0,0 +1,91 @@
"""The catalog entry of the OCI menu: it names the new applications and leads
to them; a refresh the user asks for says when it could not be done."""
from pathlib import Path
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from proxmenux_oci import cli
APPS = [{'id': 'alpha', 'title': 'Alpha', 'architectures': ['amd64']},
{'id': 'beta', 'title': 'Beta', 'architectures': ['amd64']}]
class NewApplicationsListTests(unittest.TestCase):
def test_opening_the_list_acknowledges_the_new_apps_and_offers_them(self):
refresh = Mock()
ui = Mock()
ui.choose.side_effect = ['1', None]
with patch.object(cli, '_app_detail') as detail:
self.assertFalse(cli._new_applications(Mock(), ui, APPS, refresh))
refresh.mark_seen.assert_called_once_with()
detail.assert_called_once()
options = ui.choose.call_args_list[0].args[1]
self.assertEqual(options[0], ('refresh', 'Refresh catalog'))
self.assertIn('New applications (2)', ui.choose.call_args_list[0].kwargs['title'])
def test_its_refresh_line_asks_for_a_refresh(self):
ui = Mock()
ui.choose.return_value = 'refresh'
self.assertTrue(cli._new_applications(Mock(), ui, APPS, Mock()))
class ManualRefreshTests(unittest.TestCase):
def run_menu(self, failed):
status = Mock(failed=failed)
status.consume.return_value = False
status.new_ids.return_value = []
status.label.return_value = 'Refresh catalog'
context = Mock()
context.__enter__ = Mock(return_value=status)
context.__exit__ = Mock(return_value=False)
checker = Mock(active=False)
checker.status.return_value = {'available': []}
checker.notice.return_value = ''
checker.label.return_value = 'Check for updates'
checker_context = Mock()
checker_context.__enter__ = Mock(return_value=checker)
checker_context.__exit__ = Mock(return_value=False)
ui = Mock(live_refresh=True)
ui.choose.side_effect = ['refresh', None]
catalog = Mock()
catalog.index_path.exists.return_value = True
catalog.load_index.return_value = {'applications': []}
with patch('proxmenux_oci.catalog_refresh.CatalogRefresh', return_value=context), \
patch('proxmenux_oci.updates.UpdateCheck', return_value=checker_context), \
patch.object(cli, 'interactive_ui', return_value=ui):
self.assertEqual(cli.interactive(catalog), 0)
status.start.assert_called_once_with(force=True)
status.wait.assert_called_once_with()
return [call.args[0] for call in ui.message.call_args_list]
def test_a_failed_refresh_asked_for_is_reported(self):
self.assertEqual(self.run_menu(True), ['Catalog refresh unavailable; using the local catalog.'])
def test_a_successful_one_says_nothing(self):
self.assertEqual(self.run_menu(False), [])
class AlignedCountsTests(unittest.TestCase):
def test_counts_line_up_two_spaces_past_the_longest_counted_entry(self):
entries = cli._aligned([('all', 'All applications (554)'),
('manage', 'Gestionar aplicaciones OCI instaladas (32)'),
('updates', 'Checking for updates (3/28)'), ('1', 'DNS (2)')])
self.assertEqual(entries[0][1], f"{'All applications':<39}(554)")
self.assertEqual(entries[1][1], 'Gestionar aplicaciones OCI instaladas ( 32)')
self.assertEqual(entries[2][1], 'Checking for updates (3/28)') # progress keeps its form
self.assertEqual(entries[3][1], f"{'DNS':<39}( 2)")
def test_the_installed_count_is_left_out_while_the_registry_is_busy(self):
with patch('proxmenux_oci.management.saved_inventory', side_effect=BlockingIOError):
self.assertIsNone(cli._installed_count())
with patch('proxmenux_oci.management.saved_inventory', return_value=[{}, {}]):
self.assertEqual(cli._installed_count(), 2)
if __name__ == '__main__':
unittest.main()
+25 -21
View File
@@ -95,7 +95,7 @@ class CatalogRefreshTests(unittest.TestCase):
self.assertNotIn("sample-added", refresh.visible_recipes(catalog))
self.assertEqual(set(refresh.visible_recipes(catalog)), set(status.recipes))
status.recipes = refresh.visible_recipes(catalog)
self.assertIn("New applications since your last visit: 0", status.describe())
self.assertEqual(status.new_ids(), [])
def test_compatibility_gate_uses_effective_menu_visibility(self):
self.add_app()
@@ -330,24 +330,27 @@ class CatalogRefreshTests(unittest.TestCase):
self.assertFalse(refresh.read_json(refresh.cache_root(self.root) / "main-result.json")["ok"])
self.assertEqual(refresh.current_catalog(self.root), self.root / "catalog")
def test_first_visit_has_zero_new_apps_and_second_visit_clears_acknowledged_news(self):
def test_first_visit_has_no_new_apps_and_opening_their_list_acknowledges_them(self):
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
self.assertIn("New applications since your last visit: 0", status.describe())
self.assertEqual((status.new_ids(), status.label()), ([], "Refresh catalog"))
status.recipes["new-app"] = "new-recipe-hash"
self.assertIn("New applications since your last visit: 1", status.describe())
status.mark_seen()
self.assertEqual((status.new_ids(), status.label()), (["new-app"], "New applications (1)"))
# A new application stays new on later visits until its list is opened.
later = refresh.CatalogRefresh(self.root, Catalog(self.root))
later.recipes = status.recipes.copy()
self.assertEqual(later.new_ids(), ["new-app"])
later.mark_seen()
same = refresh.CatalogRefresh(self.root, Catalog(self.root))
same.recipes = status.recipes.copy()
self.assertIn("New applications since your last visit: 0", same.describe())
self.assertEqual(same.new_ids(), [])
def test_new_app_count_is_not_net_growth_and_modified_recipes_are_separate(self):
def test_new_apps_are_counted_by_id_not_net_growth_or_changed_recipes(self):
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
original = self.item["id"]
status.baseline = {original: "old-recipe", "removed-app": "hash"}
status.recipes["new-app"] = "hash"
self.assertIn("Available applications: 2", status.describe())
self.assertIn("New applications since your last visit: 1", status.describe())
self.assertIn("Updated recipes since your last visit: 1", status.describe())
self.assertEqual(status.new_ids(), ["new-app"])
self.assertEqual(status.label(), "New applications (1)")
def test_exit_before_download_finishes_does_not_acknowledge_remote_apps(self):
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
@@ -359,7 +362,7 @@ class CatalogRefreshTests(unittest.TestCase):
self.add_app()
self.sync()
next_visit = refresh.CatalogRefresh(self.root, Catalog(self.root))
self.assertIn("New applications since your last visit: 1", next_visit.describe())
self.assertEqual(len(next_visit.new_ids()), 1)
def test_the_download_never_closes_the_open_menu_and_shows_on_the_next_draw(self):
from proxmenux_oci import cli, updates
@@ -400,12 +403,14 @@ class CatalogRefreshTests(unittest.TestCase):
patch.object(cli, '_search'), \
patch.object(updates, 'UpdateCheck', return_value=checker_context):
self.assertEqual(cli.interactive(Catalog(self.root)), 0)
self.assertIn('Available applications: 1', screens[0][0])
self.assertIn('Available applications: 2', screens[1][0])
self.assertIn('New applications since your last visit: 1', screens[1][0])
# The header is one line; the catalog entry names the new application.
self.assertEqual(screens[0][0], cli.translate('Select a category or search for applications:'))
self.assertEqual(screens[0][1]['refresh'], cli.translate('Refresh catalog'))
self.assertRegex(screens[1][1]['refresh'], r' {2,}\( 1\)$')
self.assertIn('( 2)', screens[1][1]['all'])
next_visit = original_class(self.root, Catalog(self.root))
self.assertIn('New applications since your last visit: 0', next_visit.describe())
# Seeing the menu does not acknowledge it: only opening the list does.
self.assertEqual(len(next_visit.new_ids()), 1)
def test_explicit_local_apps_survive_refresh_without_preventing_upstream_removal(self):
# This app is a local preview, not a member of the upstream manifest.
@@ -450,7 +455,7 @@ class CatalogRefreshTests(unittest.TestCase):
with status:
self.assertTrue(status.active)
# The download is transparent: the open menu does not announce it.
self.assertNotIn("Refreshing", status.describe())
self.assertEqual(status.label(), "Refresh catalog")
thread.return_value.start.assert_called_once()
spawn.return_value.wait.assert_not_called()
@@ -473,7 +478,7 @@ class CatalogRefreshTests(unittest.TestCase):
def test_manual_refresh_is_offered_in_the_main_menu(self):
from proxmenux_oci.cli import _catalog_options
options = dict(_catalog_options(Catalog(self.root))[-1])
self.assertEqual(options['catalog-refresh'], 'Refresh catalog')
self.assertEqual(options['refresh'], 'Refresh catalog')
def test_sequence_new_app_failed_update_retry_and_withdrawal(self):
self.sync()
@@ -486,7 +491,7 @@ class CatalogRefreshTests(unittest.TestCase):
self.assertIn('sample-added', refresh.visible_recipes(Catalog(self.root)))
self.assertNotIn('sample-added', refresh.visible_recipes(old)) # Wizard remains pinned.
next_visit = refresh.CatalogRefresh(self.root, Catalog(self.root))
self.assertIn('New applications since your last visit: 1', next_visit.describe())
self.assertEqual(len(next_visit.new_ids()), 1)
next_visit.mark_seen()
self.revision = 'c' * 40
recipe_path = self.item['curated_path'].removeprefix('catalog/')
@@ -507,8 +512,7 @@ class CatalogRefreshTests(unittest.TestCase):
current = Catalog(self.root)
self.assertNotIn('sample-added', refresh.visible_recipes(current))
self.assertFalse((current.catalog_dir / 'curated/sample-added.json').exists())
self.assertIn('New applications since your last visit: 0',
refresh.CatalogRefresh(self.root, current).describe())
self.assertEqual(refresh.CatalogRefresh(self.root, current).new_ids(), [])
def test_http_errors_and_timeouts_keep_snapshot_then_retry_succeeds(self):
self.sync()
@@ -660,7 +664,7 @@ class CatalogRefreshTests(unittest.TestCase):
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
with patch.object(refresh, 'write_json', side_effect=PermissionError('Read only')):
status.mark_seen()
self.assertTrue(status.failed)
self.assertFalse(status.failed)
def test_concurrent_workers_serialize_the_catalog_transaction(self):
barrier = threading.Barrier(2)
+2 -1
View File
@@ -132,7 +132,8 @@ class RegistryMenuTests(unittest.TestCase):
refresher = refresh_type.return_value.__enter__.return_value
refresher.active = False
refresher.consume.return_value = False
refresher.describe.return_value = ''
refresher.failed = False
refresher.label.return_value = 'Refresh catalog'
self.assertEqual(cli.interactive(catalog), 0)
open_accounts.assert_called_once_with(ui)
+6 -4
View File
@@ -280,9 +280,9 @@ class MainMenuTests(unittest.TestCase):
checker.running = False; checker.done = True
raise MenuRefresh()
return None
catalog_refresh = Mock(active=False)
catalog_refresh = Mock(active=False, failed=False)
catalog_refresh.consume.return_value = False
catalog_refresh.describe.return_value = 'Catalog: 0 applications'
catalog_refresh.label.return_value = 'Refresh catalog'
catalog_context = Mock()
catalog_context.__enter__ = Mock(return_value=catalog_refresh)
catalog_context.__exit__ = Mock(return_value=False)
@@ -290,8 +290,10 @@ class MainMenuTests(unittest.TestCase):
patch.object(cli, 'interactive_ui', return_value=UI()), \
patch.object(updates, 'UpdateCheck', return_value=checker):
self.assertEqual(cli.interactive(cli.Catalog(ROOT)), 0)
self.assertEqual(seen, ['Check for updates', 'Checking for updates (0/2)',
'Checking for updates (1/2)', 'Updates available (2)'])
# A count lines up with the counts of the categories; progress does not.
self.assertEqual(seen[:3], ['Check for updates', 'Checking for updates (0/2)',
'Checking for updates (1/2)'])
self.assertRegex(seen[3], r'^Updates available {2,}\( 2\)$')
class TranslationTests(unittest.TestCase):