diff --git a/lang/de.json b/lang/de.json index f9fb1fe8..9458766c 100644 --- a/lang/de.json +++ b/lang/de.json @@ -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})" } diff --git a/lang/es.json b/lang/es.json index e897f707..f2323e33 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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})" } diff --git a/lang/fr.json b/lang/fr.json index a07b0be0..5dafd327 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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})" } diff --git a/lang/it.json b/lang/it.json index c743e83a..c1174d46 100644 --- a/lang/it.json +++ b/lang/it.json @@ -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})" } diff --git a/lang/pt.json b/lang/pt.json index 1b94d80e..30332e02 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -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})" } diff --git a/lang/sk.json b/lang/sk.json index 949345b1..bcc64da8 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -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})" } diff --git a/lang/sv.json b/lang/sv.json index 9036e7df..fde651b9 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -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})" } diff --git a/oci/src/proxmenux_oci/catalog_refresh.py b/oci/src/proxmenux_oci/catalog_refresh.py index 138f37e4..30dded5b 100644 --- a/oci/src/proxmenux_oci/catalog_refresh.py +++ b/oci/src/proxmenux_oci/catalog_refresh.py @@ -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__": diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py index 84e2d26c..76706924 100644 --- a/oci/src/proxmenux_oci/cli.py +++ b/oci/src/proxmenux_oci/cli.py @@ -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']: diff --git a/oci/tests/test_catalog_new_apps_entry.py b/oci/tests/test_catalog_new_apps_entry.py new file mode 100644 index 00000000..aa536dfb --- /dev/null +++ b/oci/tests/test_catalog_new_apps_entry.py @@ -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() diff --git a/oci/tests/test_catalog_refresh.py b/oci/tests/test_catalog_refresh.py index a7aa729f..fdb049bc 100644 --- a/oci/tests/test_catalog_refresh.py +++ b/oci/tests/test_catalog_refresh.py @@ -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) diff --git a/oci/tests/test_registry_accounts.py b/oci/tests/test_registry_accounts.py index 55faeea3..37235012 100644 --- a/oci/tests/test_registry_accounts.py +++ b/oci/tests/test_registry_accounts.py @@ -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) diff --git a/oci/tests/test_update_menu.py b/oci/tests/test_update_menu.py index 05fa85d5..84ef3274 100644 --- a/oci/tests/test_update_menu.py +++ b/oci/tests/test_update_menu.py @@ -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):