mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-04 20:02:07 +00:00
feat(oci): show new and installed application counts in the OCI menu entries, lined up in one column
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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})"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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']:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user