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 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.", "⚠ 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 /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 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.", "⚠ 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 /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 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.", "⚠ 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 /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 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.", "⚠ 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 /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 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.", "⚠ 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 /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 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.", "⚠ 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 /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 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.", "⚠ 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 /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.recipes = visible_recipes(catalog)
self.displayed_directory = catalog.catalog_dir self.displayed_directory = catalog.catalog_dir
baseline = read_json(self.seen_path).get("recipes") baseline = read_json(self.seen_path).get("recipes")
self.baseline = baseline if isinstance(baseline, dict) else self.recipes.copy()
self.process = None self.process = None
self.failed = False self.failed = False
self._finished = 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): def __enter__(self):
self.start() self.start()
@@ -480,8 +485,8 @@ class CatalogRefresh:
return True return True
def __exit__(self, *args): def __exit__(self, *args):
# The worker can finish downloading after the user leaves. Its result # The worker can finish downloading after the user leaves. Its new
# is not marked seen until a later menu actually displays that catalog. # applications stay new until the user opens their list.
if self.process and self.process.poll() is not None: if self.process and self.process.poll() is not None:
self.process.wait() self.process.wait()
elif self.process: elif self.process:
@@ -506,22 +511,26 @@ class CatalogRefresh:
self.displayed_directory = directory self.displayed_directory = directory
return changed return changed
def describe(self): def new_ids(self):
text = translate("Available applications: {count}").format(count=len(self.recipes)) """The applications added since the user last opened the list of new ones."""
added = len(self.recipes.keys() - self.baseline.keys()) return sorted(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) def label(self):
if updated: added = len(self.new_ids())
text += "\n" + translate("Updated recipes since your last visit: {count}").format(count=updated) return (translate("New applications ({count})").format(count=added) if added
if self.failed: else translate("Refresh catalog"))
text += "\n" + translate("Catalog refresh unavailable; using the local catalog.")
return text def wait(self):
if self.process is not None and not self._finished:
self.process.wait()
def mark_seen(self): def mark_seen(self):
"""Opening the list of new applications makes them known."""
self.baseline = self.recipes.copy()
try: try:
write_json(self.seen_path, {"recipes": self.recipes}) write_json(self.seen_path, {"recipes": self.recipes})
except OSError: except OSError:
self.failed = True pass
if __name__ == "__main__": 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]) _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: 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() query = ui.ask(translate("Name or part of the description of the application"), required=False).strip()
if not query: if not query:
@@ -557,6 +576,29 @@ def _search(catalog: Catalog, ui, applications: list[dict[str, Any]]) -> None:
keep_order=True) 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): def _catalog_options(catalog):
applications = _installable(catalog.load_index()["applications"]) applications = _installable(catalog.load_index()["applications"])
categories: dict[str, list[dict[str, Any]]] = {} 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)} category_by_index = {str(number): key for number, key in enumerate(order, 1)}
options = [ options = [
("search", translate("Search applications")), ("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")), ("manage", translate("Manage installed OCI applications")),
("catalog-refresh", translate("Refresh catalog")), ("refresh", translate("Refresh catalog")),
("registries", translate("Registry accounts")), ("registries", translate("Registry accounts")),
("custom", translate("Install an image that is not in the catalog")), ("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()] for number, key in category_by_index.items()]
return applications, categories, labels, category_by_index, options return applications, categories, labels, category_by_index, options
@@ -590,15 +632,30 @@ def interactive(catalog: Catalog) -> int:
return 1 return 1
applications, categories, labels, category_by_index, options = _catalog_options(catalog) applications, categories, labels, category_by_index, options = _catalog_options(catalog)
selection = "search" selection = "search"
refresh_requested = False
with CatalogRefresh(PROJECT_ROOT, catalog) as catalog_refresh, UpdateCheck(PROJECT_ROOT) as updates: with CatalogRefresh(PROJECT_ROOT, catalog) as catalog_refresh, UpdateCheck(PROJECT_ROOT) as updates:
while True: while True:
if catalog_refresh.consume(): if catalog_refresh.consume():
catalog = Catalog(PROJECT_ROOT) catalog = Catalog(PROJECT_ROOT)
applications, categories, labels, category_by_index, options = _catalog_options(catalog) applications, categories, labels, category_by_index, options = _catalog_options(catalog)
catalog_refresh.recipes = visible_recipes(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() state = updates.status()
entries = ReadableTags(options[:3] + [('updates', updates.label(state))] + options[3:]) # The catalog entry names the new applications when there are some.
text = catalog_refresh.describe() + "\n\n" + translate("Select a category or search for applications:") 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): if notice := updates.notice(state):
text += '\n\n' + notice text += '\n\n' + notice
if updates.active and not getattr(ui, 'live_refresh', False): 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) selected = ui.choose(text, entries, selection, size=MENU_SIZE, **kwargs)
except MenuRefresh: except MenuRefresh:
continue continue
finally:
catalog_refresh.mark_seen()
if selected is None: if selected is None:
return 0 return 0
selection = selected selection = selected
@@ -626,8 +681,14 @@ def interactive(catalog: Catalog) -> int:
elif selection == "manage": elif selection == "manage":
from .management import interactive_management from .management import interactive_management
interactive_management(PROJECT_ROOT, ui) interactive_management(PROJECT_ROOT, ui)
elif selection == "catalog-refresh": elif selection == "refresh":
catalog_refresh.start(force=True) 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": elif selection == "updates":
if not updates.active: if not updates.active:
if state['available']: 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.assertNotIn("sample-added", refresh.visible_recipes(catalog))
self.assertEqual(set(refresh.visible_recipes(catalog)), set(status.recipes)) self.assertEqual(set(refresh.visible_recipes(catalog)), set(status.recipes))
status.recipes = refresh.visible_recipes(catalog) 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): def test_compatibility_gate_uses_effective_menu_visibility(self):
self.add_app() 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.assertFalse(refresh.read_json(refresh.cache_root(self.root) / "main-result.json")["ok"])
self.assertEqual(refresh.current_catalog(self.root), self.root / "catalog") 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)) 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" status.recipes["new-app"] = "new-recipe-hash"
self.assertIn("New applications since your last visit: 1", status.describe()) self.assertEqual((status.new_ids(), status.label()), (["new-app"], "New applications (1)"))
status.mark_seen() # 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 = refresh.CatalogRefresh(self.root, Catalog(self.root))
same.recipes = status.recipes.copy() 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)) status = refresh.CatalogRefresh(self.root, Catalog(self.root))
original = self.item["id"] original = self.item["id"]
status.baseline = {original: "old-recipe", "removed-app": "hash"} status.baseline = {original: "old-recipe", "removed-app": "hash"}
status.recipes["new-app"] = "hash" status.recipes["new-app"] = "hash"
self.assertIn("Available applications: 2", status.describe()) self.assertEqual(status.new_ids(), ["new-app"])
self.assertIn("New applications since your last visit: 1", status.describe()) self.assertEqual(status.label(), "New applications (1)")
self.assertIn("Updated recipes since your last visit: 1", status.describe())
def test_exit_before_download_finishes_does_not_acknowledge_remote_apps(self): def test_exit_before_download_finishes_does_not_acknowledge_remote_apps(self):
status = refresh.CatalogRefresh(self.root, Catalog(self.root)) status = refresh.CatalogRefresh(self.root, Catalog(self.root))
@@ -359,7 +362,7 @@ class CatalogRefreshTests(unittest.TestCase):
self.add_app() self.add_app()
self.sync() self.sync()
next_visit = refresh.CatalogRefresh(self.root, Catalog(self.root)) 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): def test_the_download_never_closes_the_open_menu_and_shows_on_the_next_draw(self):
from proxmenux_oci import cli, updates from proxmenux_oci import cli, updates
@@ -400,12 +403,14 @@ class CatalogRefreshTests(unittest.TestCase):
patch.object(cli, '_search'), \ patch.object(cli, '_search'), \
patch.object(updates, 'UpdateCheck', return_value=checker_context): patch.object(updates, 'UpdateCheck', return_value=checker_context):
self.assertEqual(cli.interactive(Catalog(self.root)), 0) self.assertEqual(cli.interactive(Catalog(self.root)), 0)
self.assertIn('Available applications: 1', screens[0][0]) # The header is one line; the catalog entry names the new application.
self.assertIn('Available applications: 2', screens[1][0]) self.assertEqual(screens[0][0], cli.translate('Select a category or search for applications:'))
self.assertIn('New applications since your last visit: 1', screens[1][0]) 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']) self.assertIn('( 2)', screens[1][1]['all'])
next_visit = original_class(self.root, Catalog(self.root)) 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): 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. # This app is a local preview, not a member of the upstream manifest.
@@ -450,7 +455,7 @@ class CatalogRefreshTests(unittest.TestCase):
with status: with status:
self.assertTrue(status.active) self.assertTrue(status.active)
# The download is transparent: the open menu does not announce it. # 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() thread.return_value.start.assert_called_once()
spawn.return_value.wait.assert_not_called() 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): def test_manual_refresh_is_offered_in_the_main_menu(self):
from proxmenux_oci.cli import _catalog_options from proxmenux_oci.cli import _catalog_options
options = dict(_catalog_options(Catalog(self.root))[-1]) 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): def test_sequence_new_app_failed_update_retry_and_withdrawal(self):
self.sync() self.sync()
@@ -486,7 +491,7 @@ class CatalogRefreshTests(unittest.TestCase):
self.assertIn('sample-added', refresh.visible_recipes(Catalog(self.root))) self.assertIn('sample-added', refresh.visible_recipes(Catalog(self.root)))
self.assertNotIn('sample-added', refresh.visible_recipes(old)) # Wizard remains pinned. self.assertNotIn('sample-added', refresh.visible_recipes(old)) # Wizard remains pinned.
next_visit = refresh.CatalogRefresh(self.root, Catalog(self.root)) 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() next_visit.mark_seen()
self.revision = 'c' * 40 self.revision = 'c' * 40
recipe_path = self.item['curated_path'].removeprefix('catalog/') recipe_path = self.item['curated_path'].removeprefix('catalog/')
@@ -507,8 +512,7 @@ class CatalogRefreshTests(unittest.TestCase):
current = Catalog(self.root) current = Catalog(self.root)
self.assertNotIn('sample-added', refresh.visible_recipes(current)) self.assertNotIn('sample-added', refresh.visible_recipes(current))
self.assertFalse((current.catalog_dir / 'curated/sample-added.json').exists()) self.assertFalse((current.catalog_dir / 'curated/sample-added.json').exists())
self.assertIn('New applications since your last visit: 0', self.assertEqual(refresh.CatalogRefresh(self.root, current).new_ids(), [])
refresh.CatalogRefresh(self.root, current).describe())
def test_http_errors_and_timeouts_keep_snapshot_then_retry_succeeds(self): def test_http_errors_and_timeouts_keep_snapshot_then_retry_succeeds(self):
self.sync() self.sync()
@@ -660,7 +664,7 @@ class CatalogRefreshTests(unittest.TestCase):
status = refresh.CatalogRefresh(self.root, Catalog(self.root)) status = refresh.CatalogRefresh(self.root, Catalog(self.root))
with patch.object(refresh, 'write_json', side_effect=PermissionError('Read only')): with patch.object(refresh, 'write_json', side_effect=PermissionError('Read only')):
status.mark_seen() status.mark_seen()
self.assertTrue(status.failed) self.assertFalse(status.failed)
def test_concurrent_workers_serialize_the_catalog_transaction(self): def test_concurrent_workers_serialize_the_catalog_transaction(self):
barrier = threading.Barrier(2) 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 = refresh_type.return_value.__enter__.return_value
refresher.active = False refresher.active = False
refresher.consume.return_value = 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) self.assertEqual(cli.interactive(catalog), 0)
open_accounts.assert_called_once_with(ui) 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 checker.running = False; checker.done = True
raise MenuRefresh() raise MenuRefresh()
return None return None
catalog_refresh = Mock(active=False) catalog_refresh = Mock(active=False, failed=False)
catalog_refresh.consume.return_value = 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 = Mock()
catalog_context.__enter__ = Mock(return_value=catalog_refresh) catalog_context.__enter__ = Mock(return_value=catalog_refresh)
catalog_context.__exit__ = Mock(return_value=False) 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(cli, 'interactive_ui', return_value=UI()), \
patch.object(updates, 'UpdateCheck', return_value=checker): patch.object(updates, 'UpdateCheck', return_value=checker):
self.assertEqual(cli.interactive(cli.Catalog(ROOT)), 0) self.assertEqual(cli.interactive(cli.Catalog(ROOT)), 0)
self.assertEqual(seen, ['Check for updates', 'Checking for updates (0/2)', # A count lines up with the counts of the categories; progress does not.
'Checking for updates (1/2)', 'Updates available (2)']) 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): class TranslationTests(unittest.TestCase):